From 71d161512f81047f5f76abecffcf2ad4e31e72c5 Mon Sep 17 00:00:00 2001 From: spbkgw-beep Date: Sun, 6 Sep 2026 21:53:28 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20agent-memory=20v0.1.0=20=E2=80=94=20cro?= =?UTF-8?q?ss-session=20memory=20for=20coding=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First release of the agent-memory-cli distribution, which installs the agent-memory command. Python 3.10+, no runtime dependencies. Creates and operates a two-tier memory home. `init` and `org init` scaffold it from bundled templates and bind a repository; `setup claude|codex` installs a coding agent's session-start hook and memory workflow file; `startup` prints the session-start read manifest; `capture` and `recall` carry decisions across sessions; `debrief write` publishes session records; `archive` and `restore` move supplementary files; `status` and `doctor` report where the home resolved and whether its layout and sync are sound. Optional git sync operates the home as its own repository behind an explicit publication allowlist that refuses never-synced names at any depth. Adds the tag-triggered release workflow: a `v*` tag runs preflight, checks the tag against the packaged distribution version, and publishes to PyPI with build attestations from the `pypi` environment. Every action is pinned to a full commit SHA. Fills in the Apache copyright line, which was still the unedited template placeholder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GeBdcy43H9CZkcjk4Jvt8n --- .github/workflows/ci.yml | 38 + .github/workflows/release.yml | 68 ++ .gitignore | 22 + CHANGELOG.md | 23 + LICENSE | 2 +- Makefile | 21 + README.md | 231 +++- docs/commands.md | 160 +++ docs/images/project-memory.jpg | Bin 0 -> 48712 bytes pyproject.toml | 73 ++ scripts/check_installed_wheel.py | 210 ++++ src/agent_memory/__init__.py | 9 + src/agent_memory/__main__.py | 9 + src/agent_memory/archive.py | 359 ++++++ src/agent_memory/cli.py | 437 +++++++ src/agent_memory/debrief.py | 336 ++++++ src/agent_memory/doctor.py | 710 +++++++++++ src/agent_memory/git_runner.py | 68 ++ src/agent_memory/home.py | 246 ++++ src/agent_memory/layout.py | 198 ++++ src/agent_memory/org.py | 218 ++++ src/agent_memory/publication.py | 433 +++++++ src/agent_memory/setup/__init__.py | 53 + src/agent_memory/setup/claude.py | 34 + src/agent_memory/setup/codex.py | 39 + src/agent_memory/setup/common.py | 1015 ++++++++++++++++ src/agent_memory/setup/legacy.py | 67 ++ src/agent_memory/startup.py | 254 ++++ src/agent_memory/status.py | 137 +++ src/agent_memory/sync.py | 649 ++++++++++ .../templates/org-memory/decisions.md | 5 + .../templates/org-memory/recent.md | 16 + .../templates/org-memory/rules.md | 5 + .../templates/project-memory/decision_log.md | 3 + .../templates/project-memory/known_debt.md | 8 + .../templates/project-memory/open_threads.md | 3 + .../templates/project-memory/project_facts.md | 4 + src/agent_memory/templates/workflow/SKILL.md | 42 + src/agent_memory/workflow.py | 287 +++++ .../org_memory/cases/bad_layout/expected.json | 10 + .../2026/08/20260825-alice-abc12345.md | 14 + .../2026/08/20260825-alice-1f3a9c2b.md | 14 + .../2026/08/20260825-alice-ABCD.md | 14 + .../2026/08/20260825_alice_abc12345.md | 14 + .../2026/09/20260825-alice-def12345.md | 14 + .../demo-project/20260825-alice-abc12345.md | 14 + .../cases/empty_store/expected.json | 4 + .../empty_store/org-memory/debriefs/.gitkeep | 0 .../cases/missing_debriefs_dir/expected.json | 10 + .../missing_debriefs_dir/org-memory/recent.md | 1 + .../cases/staging_artifact/expected.json | 10 + .../.stage.20260825-alice-77xx88yy.md.a1b2c3 | 1 + .../2026/08/20260825-alice-1f3a9c2b.md | 14 + .../cases/valid_store/expected.json | 4 + .../valid_store/org-memory/debriefs/.gitkeep | 0 .../2026/08/20260825-bob-ops-9f00aa11.md | 14 + .../2026/08/20260825-alice-1f3a9c2b.md | 14 + .../2026/07/20260701-Alice_2.dev-00aa11bb.md | 14 + .../2026/12/20261203-bob-9e0d44aa.md | 14 + tests/conftest.py | 60 + .../fixtures/setup/claude_settings_fleet.json | 40 + tests/fixtures/setup/codex_hooks_kernel.json | 29 + tests/golden/canonical_memory_gitignore.txt | 9 + tests/golden/debrief_record.md | 14 + tests/golden/doctor_memory_0.4.5.json | 125 ++ tests/golden/doctor_memory_0.4.5.txt | 28 + tests/test_archive.py | 650 ++++++++++ tests/test_cli.py | 300 +++++ tests/test_debrief.py | 1051 +++++++++++++++++ tests/test_doctor.py | 742 ++++++++++++ tests/test_home.py | 286 +++++ tests/test_layout.py | 222 ++++ tests/test_org.py | 404 +++++++ tests/test_setup.py | 852 +++++++++++++ tests/test_startup.py | 384 ++++++ tests/test_status.py | 181 +++ tests/test_sync.py | 854 ++++++++++++++ tests/test_workflow.py | 359 ++++++ 78 files changed, 13274 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Makefile create mode 100644 docs/commands.md create mode 100644 docs/images/project-memory.jpg create mode 100644 pyproject.toml create mode 100644 scripts/check_installed_wheel.py create mode 100644 src/agent_memory/__init__.py create mode 100644 src/agent_memory/__main__.py create mode 100644 src/agent_memory/archive.py create mode 100644 src/agent_memory/cli.py create mode 100644 src/agent_memory/debrief.py create mode 100644 src/agent_memory/doctor.py create mode 100644 src/agent_memory/git_runner.py create mode 100644 src/agent_memory/home.py create mode 100644 src/agent_memory/layout.py create mode 100644 src/agent_memory/org.py create mode 100644 src/agent_memory/publication.py create mode 100644 src/agent_memory/setup/__init__.py create mode 100644 src/agent_memory/setup/claude.py create mode 100644 src/agent_memory/setup/codex.py create mode 100644 src/agent_memory/setup/common.py create mode 100644 src/agent_memory/setup/legacy.py create mode 100644 src/agent_memory/startup.py create mode 100644 src/agent_memory/status.py create mode 100644 src/agent_memory/sync.py create mode 100644 src/agent_memory/templates/org-memory/decisions.md create mode 100644 src/agent_memory/templates/org-memory/recent.md create mode 100644 src/agent_memory/templates/org-memory/rules.md create mode 100644 src/agent_memory/templates/project-memory/decision_log.md create mode 100644 src/agent_memory/templates/project-memory/known_debt.md create mode 100644 src/agent_memory/templates/project-memory/open_threads.md create mode 100644 src/agent_memory/templates/project-memory/project_facts.md create mode 100644 src/agent_memory/templates/workflow/SKILL.md create mode 100644 src/agent_memory/workflow.py create mode 100644 tests/conformance/org_memory/cases/bad_layout/expected.json create mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md create mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md create mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md create mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md create mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md create mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md create mode 100644 tests/conformance/org_memory/cases/empty_store/expected.json create mode 100644 tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep create mode 100644 tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json create mode 100644 tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md create mode 100644 tests/conformance/org_memory/cases/staging_artifact/expected.json create mode 100644 tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 create mode 100644 tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md create mode 100644 tests/conformance/org_memory/cases/valid_store/expected.json create mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep create mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md create mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md create mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md create mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/setup/claude_settings_fleet.json create mode 100644 tests/fixtures/setup/codex_hooks_kernel.json create mode 100644 tests/golden/canonical_memory_gitignore.txt create mode 100644 tests/golden/debrief_record.md create mode 100644 tests/golden/doctor_memory_0.4.5.json create mode 100644 tests/golden/doctor_memory_0.4.5.txt create mode 100644 tests/test_archive.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_debrief.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_home.py create mode 100644 tests/test_layout.py create mode 100644 tests/test_org.py create mode 100644 tests/test_setup.py create mode 100644 tests/test_startup.py create mode 100644 tests/test_status.py create mode 100644 tests/test_sync.py create mode 100644 tests/test_workflow.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2e9af2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + preflight: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run preflight + run: make preflight + + - name: Prove the installed wheel operates a home with nothing else present + run: make wheel-check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fa84e45 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v[0-9]*" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run preflight + run: make preflight + + - name: Verify tag matches package version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + PACKAGE_VERSION=$(python -c 'from importlib.metadata import version; print(version("agent-memory-cli"))') + test "${RELEASE_TAG}" = "v${PACKAGE_VERSION}" + + - name: Upload release artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + + publish-pypi: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/agent-memory-cli + permissions: + id-token: write + + steps: + - name: Download release artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distributions to PyPI with attestations + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + attestations: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d75deaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +/.venv/ +/build/ +/dist/ +/.pytest_cache/ +/.ruff_cache/ +*.egg-info/ +__pycache__/ +*.py[cod] + +# Signing material never belongs in a memory home or its tooling +keys/ +*.pem +*.key + +# Coding-agent tooling: instruction files, per-agent configuration, workspace +# markers and worktrees. Local to a checkout, never part of the package. +.claude/ +.codex/ +CLAUDE.md +AGENTS.md +.oacp +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e7e1f50 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to agent-memory are documented in this file. The format +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.1.0 - 2026-09-06 + +### Added + +- The `agent-memory-cli` distribution installs the `agent-memory` command, with no runtime dependencies ([Quick Start](README.md#quick-start)). +- `agent-memory init` builds the two-tier memory home from bundled templates and binds a repository ([init](docs/commands.md#init)). +- Home resolution follows a fixed precedence; an ancestor it cannot inspect is an error, not a fall-through ([status](docs/commands.md#status)). +- `agent-memory setup claude|codex` installs the runtime's session-start hook and memory workflow file ([setup](docs/commands.md#setup)). +- `agent-memory startup` prints the session-start read manifest: the memory files in order, never their content ([startup](docs/commands.md#startup)). +- `capture` records one decision; `recall` prints the manifest's files, cut at a character budget ([recall](docs/commands.md#capture-and-recall)). +- `debrief write` publishes a session debrief atomically, never replacing a published record ([debrief](docs/commands.md#debrief-write)). +- `archive` and `restore` move a memory file in and out of `memory/archive/`, following no symlink ([archive](docs/commands.md#archive-and-restore)). +- `enable`, `clone`, `pull`, `push` and `disable` operate a memory home as its own git repository ([sync](docs/commands.md#sync)). +- A memory commit carries only the paths the sync allowlist selects; `keys/` is refused at any depth ([sync](docs/commands.md#sync)). +- `status` reports which home resolved, whether its layout is in place and where sync stands ([status](docs/commands.md#status)). +- `doctor` checks the debrief store and the sync repository; it reads no memory content and repairs nothing ([doctor](docs/commands.md#doctor)). +- A `v*` tag publishes the distribution to PyPI with build attestations ([PyPI](https://pypi.org/project/agent-memory-cli/)). diff --git a/LICENSE b/LICENSE index 261eeb9..027bd82 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 kiloloop Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5eeb347 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +PYTHON ?= python3 + +.PHONY: build lint preflight test wheel-check + +lint: + $(PYTHON) -m ruff check . + +test: + $(PYTHON) -m pytest -q + +build: + $(PYTHON) -m build + $(PYTHON) -m twine check dist/* + +preflight: lint test build + +# Installs the freshly built wheel in a throwaway venv (no extras, no kernel) and +# proves it scaffolds and reads a memory home; add HOME_PATH= to also run +# `status` against a live home. +wheel-check: + $(PYTHON) scripts/check_installed_wheel.py $(if $(HOME_PATH),--home $(HOME_PATH),) diff --git a/README.md b/README.md index b2e4c6e..17153e7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,231 @@ # agent-memory -cross-session memory for coding agents — plain files, git-native, no server. + +Cross-session memory for coding agents — plain files, git-native, no server. + +## Why + +Agents start sessions blank. Keep facts, decisions, and unfinished work in +Markdown you can read and edit. + +Memory travels **between agents** via project files, **between projects** via +org rules, and **between machines** via optional git sync. Claude Code and Codex +share the store. No database, daemon, server, or runtime Python dependencies. + +## Quick Start + +First time: install and bind. Every session after: recall context, capture decisions. + +### With your coding agent + +Paste this into Claude Code or Codex from your repository: + +```text +Set up cross-session memory with agent-memory-cli from +https://pypi.org/project/agent-memory-cli/ and its source, +https://github.com/kiloloop/agent-memory. + +Install with Python 3.10+. Ask for my home and project name, initialize and +bind this repository, and git-ignore the binding. Set up my runtime's hook +and workflow; report conflicts. + +Capture a decision I provide, with its reason and runtime. Show status and +doctor. Next session here, recall context and report any truncation or sync +warning. Ask for my remote before enabling optional sync; keep pushing explicit. +``` + +### Manually + +Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and +`archive`/`restore` are POSIX-only. + +1. **Install** with `uv tool install agent-memory-cli` or + `python -m pip install agent-memory-cli`. The distribution and the command + differ: installing `agent-memory-cli` gives you `agent-memory`. Put it on + your agent's PATH. +2. **Bind**, from your repository root: + + ```bash + agent-memory init --home "$HOME/agent-memory" --project my-app --repo . + ``` + + Git-ignore `.agent-memory.json`. Stay here; unset `AGENT_MEMORY_HOME`/`OACP_HOME` or point them at this + home: they precede the binding. +3. **Set up** the runtime you use, then enable the hook there if required: + + ```bash + agent-memory setup claude + # Or: + agent-memory setup codex + ``` + +4. **Record now; recall next session** in this repository: + + ```bash + agent-memory capture "Use SQLite for the cache." --why "no daemon" --agent codex + agent-memory recall + agent-memory status + agent-memory doctor + ``` + + Use `--agent claude` if appropriate; recall also works now for inspection. +5. **Optionally sync.** Use an empty private remote. `enable --remote` pushes + the initial commit; substitute its URL: + + ```bash + agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git + agent-memory push --agent codex + # After another machine pushes, with a clean local tree: + agent-memory pull + ``` + + Elsewhere, `agent-memory clone --home `, then repeat + binding and setup. Bind each machine separately; push explicitly. + +## How It Works + +![Four project memory files with sample Markdown](https://raw.githubusercontent.com/kiloloop/agent-memory/main/docs/images/project-memory.jpg) + +*Illustrative files; not a bundled application UI.* + +OACP defines the layout; this tool implements it. The four active files in +`projects//memory/` hold notes such as these trimmed samples: + +| File | Purpose and sample | +| --- | --- | +| `project_facts.md` | Stable facts: FastAPI backend; Postgres; no PII in logs. | +| `decision_log.md` | Choices: 2026-05-09 — retry 5xx three times, with backoff and jitter. | +| `open_threads.md` | Work and owners: OAuth refresh race — waiting on Codex. | +| `known_debt.md` | Problems: replace the hard-coded session TTL with a setting. | + +Date entries; supersede decisions by adding new ones. Close or pause threads, +write for humans, and distill transcripts. Edit other files directly; promote +debt to a thread when work starts. + +`org-memory/` sits beside `projects/`: `recent.md`, `decisions.md`, and +`rules.md` carry shared context; `events/` and `debriefs/` hold records. +Most notes belong to a project. Use org memory only across repositories. + +Home resolution: `--home` → `AGENT_MEMORY_HOME` → `OACP_HOME` → nearest ancestor +`.agent-memory.json` → workspace marker → `~/agent-memory`. A workspace marker +points into a home's `projects/` tree. Project selection uses `--project` or a +matching binding/marker. OACP is not required. + +Sync makes the home a git repository with an allowlist and `.oacp-memory-repo` +marker. `push` commits selected paths; `pull` fast-forwards a clean tree that +is not ahead or diverged. No merges; keys and setup receipts stay local. +Network verbs time out after 30 seconds. + +`setup` installs a SessionStart hook and memory workflow. `startup` lists +metadata for the four project files, then the three curated org files; it +injects no content. `--pull` refreshes first, warning on failure. The workflow +tells the agent to run `recall` for an 8,000-character bounded read. Raise +`--max-chars` or read remaining files directly when cut. `capture` records +decisions during work. At the end, update threads and debt, optionally publish +a summary with `debrief write`, and explicitly `push`; no push hook is installed. + +Startup and recall exclude `archive/`, `events/`, and `debriefs/`. This is not +a vector database, RAG pipeline, or chat-history store: no embeddings, +similarity queries, synthesis, or indexing. Recall reads a fixed file set. + +### Commands + +| Command | Purpose | +| --- | --- | +| [`status`][status] | Inspect home and sync. | +| [`doctor`][doctor] | Check health; repair nothing. | +| [`init`][init] | Scaffold and bind. | +| [`org init`][init] | Scaffold org memory. | +| [`enable`][sync] | Enable git sync. | +| [`clone`][sync] | Clone a memory remote. | +| [`pull`][sync] | Fast-forward from upstream. | +| [`push`][sync] | Commit selected files and push. | +| [`disable`][sync] | Disable sync. | +| [`archive`][archive-and-restore] | Archive a supplementary file. | +| [`restore`][archive-and-restore] | Restore to an empty slot. | +| [`setup`][setup] | Install runtime integration. | +| [`startup`][startup] | Print the metadata manifest. | +| [`capture`][capture-and-recall] | Record a decision. | +| [`recall`][capture-and-recall] | Read bounded context. | +| [`debrief write`][debrief-write] | Publish a session summary. | + +[status]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#status +[doctor]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#doctor +[init]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#init +[sync]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#sync +[archive-and-restore]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#archive-and-restore +[setup]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#setup +[startup]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#startup +[capture-and-recall]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#capture-and-recall +[debrief-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#debrief-write + +## Examples + +Scratch run in `/private/tmp/am-readme-demo`: init/startup/recall excerpts; +other outputs complete. + +```console +$ agent-memory init --home memory --project demo +Initialized memory home: /private/tmp/am-readme-demo/memory +``` + +```console +$ agent-memory startup --home memory --project demo --runtime claude --max-chars 420 +agent-memory startup (claude): home /private/tmp/am-readme-demo/memory (flag), project demo (flag) +Project memory, read in this order (states are readability only; no content is injected): +``` + +```console +$ agent-memory capture 'Use SQLite for the cache.' --why 'no daemon' --agent codex --home memory --project demo +captured: /private/tmp/am-readme-demo/memory/projects/demo/memory/decision_log.md (## 2026-09-07) +- **Use SQLite for the cache.** Why: no daemon (codex, 2026-09-07T01:53:42Z) +``` + +```console +$ agent-memory recall --home memory --project demo --max-chars 850 +## 2026-09-07 + +- **Use SQLite for the cache.** Why: no daemon (codex, 2026-09-07T01:53:42Z) +``` + +```console +$ agent-memory status --home memory +home: memory +source: flag +exists: yes +marker: absent +gitignore: canonical +org-memory: present +projects: 1 with a memory dir +sync: not configured +``` + +```console +$ agent-memory doctor --home memory +[+] Org Memory + [+] org-memory/debriefs/ — present + [+] debriefs/ — empty store, nothing to validate + +[-] Memory Sync + [-] .oacp-memory-repo — not configured; memory sync hooks are disabled + Run: agent-memory enable [--remote URL] + +No issues found. +``` + +## Project + +- [PyPI package: agent-memory-cli](https://pypi.org/project/agent-memory-cli/) +- [Source](https://github.com/kiloloop/agent-memory) +- [OACP](https://github.com/kiloloop/oacp) + +## License + +Apache-2.0. See [LICENSE](https://github.com/kiloloop/agent-memory/blob/main/LICENSE). + +## Development + +Activate `.venv` before running the two `make` commands. + + python3 -m venv .venv && .venv/bin/pip install -e ".[dev]" + make preflight # lint, test, build + make wheel-check # install the built wheel in a throwaway venv and prove it operates a home diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..7d04fb4 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,160 @@ +# Command reference + +Detailed command contracts for `agent-memory`. Run `agent-memory --help` +for arguments and defaults; nested verbs also have their own help. + +The home resolves, first hit wins, from `--home`, `$AGENT_MEMORY_HOME`, +`$OACP_HOME`, the nearest `.agent-memory.json` binding above the working +directory, a workspace marker (a symlink or `workspace.json` whose real path +is `/projects//workspace.json`), then `~/agent-memory`. + +## Status + +`status` prints where the home resolves and where its sync stands: the rule +that chose the home, the marker, the allowlist, the tiers, then the tree and +the upstream. It contacts the remote only with `--fetch`, and it exits 1 when +the tree is dirty or diverged (ahead and behind are reported, not failed). + +## Doctor + +`doctor` runs the two memory setup checks: the debrief store's +layout (presence, canonical paths, staging leftovers, symlinks; it never opens +a record, and a traversal it cannot finish is an error row, never a pass) and +the sync repository (marker, allowlist, tracked and untracked memory files, +tree, upstream, remote, last-commit age, per-instance state, overlay ignores). +It reads no memory content, repairs nothing, exits 1 only on an error row, +and points at `memory-lint` when that is installed; `--json` emits the same +rows as data. + +## Init + +`init` creates the home from the templates bundled in the package: the org +tier (`recent.md`, `decisions.md`, `rules.md`, `events/`, `debriefs/`) and, +with `--project`, that project's tier (`project_facts.md`, `decision_log.md`, +`open_threads.md`, `known_debt.md`, `archive/`). With `--repo` it records a +`.agent-memory.json` binding in that repository last, after every other +check, and refuses to overwrite a binding that points elsewhere. Nothing that +exists is rewritten, so a rerun changes no byte; a template missing from the +installed package is an error, not a silent fallback. No git, no network, no +credentials. `org init` is the org tier alone, for a home that already exists. + +## Sync + +`enable` makes the home a git repository of its own, puts the sync allowlist at +the head of its `.gitignore` as a managed block (existing lines are kept, and +the write comes with a before/after receipt), drops the sync marker, and makes +one commit. With `--remote`, `enable` also pushes that initial commit. `push` commits only what the allowlist selects, as a partial +commit, so anything else staged in the index stays staged and uncommitted; +it refuses a home that is behind or diverged, and a push the remote rejects +leaves the local commit in place and says so. `pull` fast-forwards only when +the tree is clean, not ahead, not diverged, and has an upstream. `clone` +brings a memory repository down; `disable` removes the marker. The network +verbs time out after 30 seconds. The engine never reads memory content, +never merges, and never touches `keys/`. It needs git 2.25 or newer. + +## Archive and restore + +`archive` moves one supplementary file from a project's `memory/` into +`memory/archive/_`, and `restore` moves it back into +an active slot that must be empty; the four active files are never archived, +under their own names or under any other name that addresses the same file +(a case variant on a case-insensitive filesystem, a hard link). Both refuse +to replace anything that exists at the instant of the move (the move is a +hard link plus unlink, so bytes and metadata are kept), and both address the +file through directory handles opened one component at a time without +following symlinks, so a symlinked directory or file is refused and a +directory swapped for a symlink after the check cannot redirect the move. +`--dry-run` runs every check, the archive path included, and reports the +same paths. POSIX only; Windows is refused. + +## Setup + +`setup claude` and `setup codex` install the runtime's session-start memory +hook in a repository: a short script (`.claude/hooks/agent-memory-pull.sh`, +`.codex/hooks/agent-memory-pull.sh`) that runs `agent-memory startup --pull` +and can never block a session (no `agent-memory` on the PATH, or a pull that +fails, is a warning), its registration in `.claude/settings.json` or +`.codex/hooks.json` added once by exact command, and a receipt in the home +(`setup//`, never synced) recording the path, the resolved symlink +target, the digest and the version. The plan is computed in full before a +byte is written (`--dry-run` prints it; `--json` for either), a rerun changes +nothing, and an interrupted run is resumed by running it again. A script +whose digest matches no shipped template is a named conflict and is kept; a +shipped template that lost its execute bit gets it back, and a script that +cannot be made runnable is never registered. Nothing is ever written through +a symlink (a linked script, settings file or hooks directory is reported +with its target), and the exit is 3 when anything was held. The hooks +earlier tooling installed are retired by exact command, and only once the +new hook is registered, so a repository is never left without one; their +scripts are removed only when the settings file was read in full, no +command in it still names the script, the bytes are what that tooling +wrote, and no symlink lies between the repository and the file. Custom +entries are untouched; the codex entry sits beside the kernel's +session-init entry and retires only its pull flag, edited in place in one +simple command (a compound command is left as written and named). No push +hook is ever installed. Beside the hook, `setup` installs the runtime's memory +workflow file, a repository skill (`.claude/skills/agent-memory/SKILL.md`, +`.agents/skills/agent-memory/SKILL.md`) rendered from one shipped text with +the runtime's name and managed by the hook's rules: written once, regenerated +only while its digest is a shipped template, kept and named as a conflict when +edited, never written through a symlink, and recorded in the receipt. + +## Startup + +`startup --runtime ` prints the session-start manifest: the +project's four active files, then the three curated org files, each with its +readability, size and modification time, and where the sync stands; with +`--pull` it fast-forwards the home first, and the files are described as +the pull left them. `events/`, `debriefs/` and `archive/` are excluded. No +content is included and no file is claimed as read +(`content_injected: false`); the text, notice included, is cut at a +character budget (`--max-chars`, at least 1). The default output is what +the runtime's hook expects on stdout (plain text for claude, the hook JSON +envelope for codex); `--json` is the manifest with its `schema_version`. The +project comes from `--project`, else from the repository's binding or +workspace marker, whichever way the home was chosen: a binding that names a +different home lends no project, and says so in the warnings. + +## Capture and recall + +`capture` and `recall` are the workflow that file describes. `capture "" +[--why TEXT] [--source REF] [--agent NAME]` appends one decision to the +project's `decision_log.md`, newest first under today's UTC date heading, with +its provenance (the agent, the UTC time, the source); it touches no other +file, never writes through a symlink (a linked directory or file below the +home is refused, not followed), replaces the file atomically keeping its +mode, and `--dry-run` composes the entry and writes nothing. `recall` prints the files +the startup manifest lists, in its order, with their content: the bounded +read at session start, cut at a character budget (`--max-chars`, default +8000) with a notice, and `archive/`, `events/` and `debriefs/` are never +loaded. Both take the project from `--project`, else from the repository's +binding or workspace marker like `startup`; `--json` for either. Neither +synthesises, searches or indexes anything. + +## Debrief write + +`debrief write` publishes one session debrief into the home's debrief store, +at `org-memory/debriefs////--.md`, +under the writer contract of the layout spec: the record is a frontmatter +block (`schema_version`, the identity fields, `started_utc`, `ended_utc`, +`content_sha256` over the exact body bytes, `immutable: true`) plus the body +verbatim; it is staged in a private file, verified through the descriptor that +wrote it, published with an atomic no-replace link, and read back. A +published record is never replaced: a differing record at the same path is a +collision (exit 2, publish under a new session id), an identical one is +idempotent, and the canonical path never holds partial bytes of the record +(one qualification, on platforms without a descriptor-bound link, follows). `--dry-run` +composes and prints the record and touches nothing; `--json` reports the +path, status and hash. Exit 1 on a validation error, 2 on a publication +failure. The publication step is one module, `agent_memory.publication`, +shared by every writer in the package. A staging entry swapped under the +writer for a link to some other file is never the published record: on Linux +the link is bound to the verified descriptor and refuses the orphaned inode, +so nothing foreign is ever visible; elsewhere (macOS, a Linux without +`/proc`) the link is by name, the foreign file is visible under the canonical +name from the link until the identity check takes that name down, and a +writer stopped in that interval leaves it there, which the next writer of the +record reports as a collision. That narrower contract rests on the store +directory not being writable by other users, its default mode, so the swap +needs the owner's own uid; it is a platform limitation. Either way the writer restages and +retries, and a swap that persists is reported with the canonical path absent. diff --git a/docs/images/project-memory.jpg b/docs/images/project-memory.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bd63e7476c17791aa71918ea0b7c0b519eb4e1d0 GIT binary patch literal 48712 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ce92632 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "agent-memory-cli" +dynamic = ["version"] +description = "Cross-session memory for coding agents: plain files, git-native, no server" +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [ + { name = "Kiloloop" }, +] +dependencies = [] +keywords = ["agents", "memory", "markdown", "git"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", +] + +[project.urls] +Homepage = "https://github.com/kiloloop/agent-memory" +Source = "https://github.com/kiloloop/agent-memory" +Changelog = "https://github.com/kiloloop/agent-memory/blob/main/CHANGELOG.md" + +[project.optional-dependencies] +dev = [ + "build>=1.2,<2", + "pytest>=8,<10", + "ruff>=0.11,<1", + "twine>=7,<8", +] + +[project.scripts] +agent-memory = "agent_memory.cli:main" + +[tool.hatch.version] +path = "src/agent_memory/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/CHANGELOG.md", + "/LICENSE", + "/Makefile", + "/README.md", + "/scripts", + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/agent_memory"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] diff --git a/scripts/check_installed_wheel.py b/scripts/check_installed_wheel.py new file mode 100644 index 0000000..2f2efab --- /dev/null +++ b/scripts/check_installed_wheel.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Prove the built wheel operates a memory home with nothing else installed. + +Run ``make build`` first. Each step is fatal on failure: + +1. create a throwaway venv and install the newest ``dist/*.whl`` plus pytest, no extras; +2. assert the installed distribution declares no runtime dependencies and that the + kernel package this tool is leaving is not importable in that venv; +3. with AGENT_MEMORY_HOME and OACP_HOME stripped from the environment, scaffold a fresh + home in a temp dir with the installed package, run ``agent-memory status --home`` and + ``agent-memory doctor --home`` on it, scaffold again, and assert no byte changed; then + ``agent-memory init --project demo --repo `` a second home: its org-tier files must + equal this checkout's templates byte for byte (the templates travelled inside the wheel), + the binding must exist, and rerunning ``init`` and ``org init`` must change no byte; + then ``agent-memory setup claude`` on that repository must write the executable hook + script, register exactly it, write a receipt in the home and change no byte on a rerun, + and ``agent-memory startup --json`` must list the seven tier files as readable; +4. optionally run ``status`` and ``doctor`` against a live home (``--home``); each may + exit 0 or 1 there (a dirty tree, an error row), never anything else; +5. run this checkout's test suite with AGENT_MEMORY_TEST_INSTALLED=1, so the import must + come from site-packages and the console script must be on PATH. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import venv +from pathlib import Path +from typing import Dict, Mapping, Optional, Sequence + +ROOT = Path(__file__).resolve().parents[1] +DIST = ROOT / "dist" +DISTRIBUTION = "agent-memory-cli" +STRIPPED_ENV = ("AGENT_MEMORY_HOME", "OACP_HOME", "PYTHONPATH") +SCAFFOLD = ( + "import sys; from pathlib import Path; from agent_memory.layout import scaffold_home; " + "print(len(scaffold_home(Path(sys.argv[1]))))" +) +ORG_FILES = ("recent.md", "decisions.md", "rules.md") +PROJECT_FILES = ("project_facts.md", "decision_log.md", "open_threads.md", "known_debt.md") + + +def newest_wheel() -> Path: + wheels = sorted(DIST.glob("agent_memory_cli-*.whl"), key=lambda path: path.stat().st_mtime) + if not wheels: + sys.exit("no wheel in dist/: run `make build` first") + return wheels[-1] + + +def venv_python(root: Path) -> Path: + if os.name == "nt": + return root / "Scripts" / "python.exe" + return root / "bin" / "python" + + +def run( + argv: Sequence[object], + env: Mapping[str, str], + *, + cwd: Optional[Path] = None, + check: bool = True, +) -> subprocess.CompletedProcess: + command = [str(item) for item in argv] + print("+", " ".join(command), flush=True) + result = subprocess.run(command, env=dict(env), cwd=cwd, text=True, capture_output=True, check=False) + if check and result.returncode != 0: + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + sys.exit(f"command failed with exit {result.returncode}: {' '.join(command)}") + return result + + +def tree_digest(root: Path) -> Dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--home", help="also run `agent-memory status --home PATH` against this live home") + parser.add_argument("--keep", action="store_true", help="keep the throwaway venv and home; print their dir") + args = parser.parse_args(argv) + + wheel = newest_wheel() + workdir = Path(tempfile.mkdtemp(prefix="agent-memory-wheel-check-")) + venv_dir = workdir / "venv" + venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) + python = venv_python(venv_dir) + env = {key: value for key, value in os.environ.items() if key not in STRIPPED_ENV} + env["PATH"] = os.pathsep.join([str(python.parent), env.get("PATH", "")]) + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + + run([python, "-m", "pip", "install", "--quiet", wheel, "pytest"], env) + + probe = run( + [python, "-c", f"import importlib.metadata as m, json; print(json.dumps(m.requires({DISTRIBUTION!r}) or []))"], + env, + ) + runtime = [entry for entry in json.loads(probe.stdout) if "extra ==" not in entry] + if runtime: + sys.exit(f"the wheel declares runtime dependencies: {runtime}") + foreign = run( + [python, "-c", "import importlib.util as u; raise SystemExit(0 if u.find_spec('oacp') is None else 1)"], + env, + check=False, + ) + if foreign.returncode != 0: + sys.exit("the kernel package is importable inside the throwaway venv; the proof would be void") + + fresh = workdir / "home" + created = int(run([python, "-c", SCAFFOLD, fresh], env).stdout) + if created == 0: + sys.exit("scaffold created nothing in an empty directory") + before = tree_digest(fresh) + console = shutil.which("agent-memory", path=str(python.parent)) + if console is None: + sys.exit("the wheel did not install the agent-memory console script") + for verb in ("status", "doctor"): + sys.stdout.write(run([console, verb, "--home", fresh], env).stdout) + if tree_digest(fresh) != before: + sys.exit("status or doctor changed the fresh home") + recreated = int(run([python, "-c", SCAFFOLD, fresh], env).stdout) + if recreated != 0 or tree_digest(fresh) != before: + sys.exit("scaffolding the same home again changed it") + + second = workdir / "home2" + repo = workdir / "repo" + repo.mkdir() + sys.stdout.write(run([console, "init", "--home", second, "--project", "demo", "--repo", repo], env).stdout) + templates = ROOT / "src" / "agent_memory" / "templates" + for tier_dir, names in (("org-memory", ORG_FILES), ("project-memory", PROJECT_FILES)): + target = second / ("org-memory" if tier_dir == "org-memory" else "projects/demo/memory") + for name in names: + if (target / name).read_bytes() != (templates / tier_dir / name).read_bytes(): + sys.exit(f"{tier_dir}/{name} written by the installed wheel differs from the source template") + if not (repo / ".agent-memory.json").is_file(): + sys.exit("init --repo recorded no binding") + snapshot = tree_digest(second) + run([console, "init", "--home", second, "--project", "demo", "--repo", repo], env) + run([console, "org", "init", "--home", second], env) + if tree_digest(second) != snapshot: + sys.exit("rerunning init or org init changed the home") + + sys.stdout.write(run([console, "setup", "claude", "--repo", repo, "--home", second], env).stdout) + hook = repo / ".claude" / "hooks" / "agent-memory-pull.sh" + if not hook.is_file() or not os.access(hook, os.X_OK): + sys.exit("setup claude wrote no executable hook script") + if not list((second / "setup" / "claude").glob("*.json")): + sys.exit("setup claude wrote no receipt in the home") + settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) + commands = [entry["command"] for group in settings["hooks"]["SessionStart"] for entry in group["hooks"]] + if commands != [".claude/hooks/agent-memory-pull.sh"]: + sys.exit(f"setup claude registered {commands}") + if "SessionEnd" in settings["hooks"]: + sys.exit("setup claude registered a session-end hook") + installed = (tree_digest(repo), tree_digest(second)) + run([console, "setup", "claude", "--repo", repo, "--home", second], env) + if (tree_digest(repo), tree_digest(second)) != installed: + sys.exit("rerunning setup claude changed a byte") + manifest = json.loads( + run([console, "startup", "--runtime", "claude", "--home", second, "--project", "demo", "--json"], env).stdout + ) + states = [entry["state"] for entry in manifest["files"]] + if manifest["schema_version"] != 1 or manifest["content_injected"] or states != ["readable"] * (len(PROJECT_FILES) + len(ORG_FILES)): + sys.exit(f"the startup manifest from the installed wheel is wrong: {json.dumps(manifest)}") + + if args.home: + for verb in ("status", "doctor"): + live = run([console, verb, "--home", args.home], env, check=False) + sys.stdout.write(live.stdout) + if live.returncode not in (0, 1): + sys.stderr.write(live.stderr) + sys.exit(f"{verb} on the live home exited {live.returncode}; 0 or 1 are its only outcomes") + + tests = run( + [python, "-m", "pytest", "-q", "tests"], + {**env, "AGENT_MEMORY_TEST_INSTALLED": "1"}, + cwd=ROOT, + check=False, + ) + sys.stdout.write(tests.stdout) + if tests.returncode != 0: + sys.stderr.write(tests.stderr) + return tests.returncode + + print( + f"installed-wheel check OK: {wheel.name} on Python {sys.version.split()[0]}; " + f"fresh home scaffolded {created} path(s), {len(before)} file(s), rerun preserved bytes" + ) + if args.keep: + print(f"kept: {workdir}") + else: + shutil.rmtree(workdir, ignore_errors=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agent_memory/__init__.py b/src/agent_memory/__init__.py new file mode 100644 index 0000000..5ad3b6c --- /dev/null +++ b/src/agent_memory/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Cross-session memory for coding agents: plain files, git-native, no server.""" + +from .home import HomeError, HomeResolution, resolve_home + +__version__ = "0.1.0" + +__all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"] diff --git a/src/agent_memory/__main__.py b/src/agent_memory/__main__.py new file mode 100644 index 0000000..07232fd --- /dev/null +++ b/src/agent_memory/__main__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Run agent-memory with ``python -m agent_memory``.""" + +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agent_memory/archive.py b/src/agent_memory/archive.py new file mode 100644 index 0000000..5688365 --- /dev/null +++ b/src/agent_memory/archive.py @@ -0,0 +1,359 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Archive and restore supplementary memory files without clobbering or escaping. + +A project's memory directory holds the active files the layout names plus +any number of supplementary files. ``archive`` moves one supplementary file +into ``memory/archive/`` under ``_``; ``restore`` +moves an archived file back to its original basename, into an active slot +that must be empty. The active files are never archived. + +Three invariants replace check-then-rename: + +* **No replace.** The move is ``os.link`` then ``os.unlink``. The link fails + if the destination exists at the instant it is made, so a file that + appears between any check and the move is never overwritten, and the + moved file keeps its bytes and metadata because it is the same inode. A + filesystem without hard links is refused rather than worked around. +* **Containment.** The project name and both basenames are validated + lexically. The home is resolved once; every directory below it + (``projects``, the project, ``memory``, ``memory/archive``) is opened one + path component at a time with ``O_NOFOLLOW``, so a symlink at any level is + refused, and the resulting directory handles address the file for every + check and for the move itself. A directory swapped for a symlink after it + was checked cannot redirect the link or the unlink: both run relative to + the handle, never by re-resolving a path. The file itself may not be a + symlink. +* **Protected identity.** An active file is protected by what it is, not by + how it is spelled: a source whose inode is one of the active files (a case + variant on a case-insensitive filesystem, a normalization variant, a hard + link) is refused like the exact name. + +A dry run performs every check, including the ones on the archive path, and +reports the same paths; only the directory creation and the move are +skipped. POSIX only: Windows is refused, dry run included. +""" + +from __future__ import annotations + +import datetime as dt +import errno +import os +import re +import stat +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple + +from . import layout + +ARCHIVE_DIR = "archive" +#: The active files of a project tier; these are never archived. +PROTECTED_FILES: Tuple[str, ...] = layout.PROJECT.files +_PLATFORM = os.name + +#: errno values a filesystem without hard links (or one that forbids them) answers os.link with. +_NO_HARD_LINKS = frozenset( + code for code in (getattr(errno, name, None) for name in ("EPERM", "EXDEV", "ENOTSUP", "EOPNOTSUPP", "EACCES")) if code +) +#: errno values open(O_NOFOLLOW) answers with when the last component is a symlink (Linux/macOS: ELOOP; BSD: EMLINK). +_IS_SYMLINK = frozenset(code for code in (getattr(errno, name, None) for name in ("ELOOP", "EMLINK")) if code) +#: Open a directory by one component, never following a symlink at that component. +_DIR_FLAGS = ( + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) +) + +_SAFE_BASENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_ARCHIVED_BASENAME_RE = re.compile(r"^(?P\d{8}T\d{6}Z)_(?P[A-Za-z0-9][A-Za-z0-9._-]{0,127})$") + + +class ArchiveError(Exception): + """The operation was refused; nothing was moved.""" + + +# --- names ----------------------------------------------------------------- + + +def validate_memory_basename(file_name: str) -> None: + if "/" in file_name or "\\" in file_name or not _SAFE_BASENAME_RE.fullmatch(file_name): + raise ArchiveError("memory file name must be a simple basename containing only [A-Za-z0-9._-]") + + +def build_archive_name(memory_file: str, now: Optional[dt.datetime] = None) -> str: + validate_memory_basename(memory_file) + current = now or dt.datetime.now(dt.timezone.utc) + return f"{current.astimezone(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}_{memory_file}" + + +def original_name_from_archive(archived_file: str) -> str: + if "/" in archived_file or "\\" in archived_file: + raise ArchiveError("archived file name must be a simple basename") + match = _ARCHIVED_BASENAME_RE.fullmatch(archived_file) + if match is None: + raise ArchiveError("archived file name must match _") + return match.group("basename") + + +# --- the verbs ------------------------------------------------------------- + + +def archive( + home: Path, + project: str, + memory_file: str, + *, + dry_run: bool = False, + now: Optional[dt.datetime] = None, +) -> Dict[str, Any]: + """Move ``memory/`` to ``memory/archive/_``.""" + _require_posix() + validate_memory_basename(memory_file) + if memory_file in PROTECTED_FILES: + raise ArchiveError(f"cannot archive standard active memory file: {memory_file}") + archived_file = build_archive_name(memory_file, now=now) + memory_dir, memory_fd = _open_memory_dir(home, project) + archive_dir = memory_dir / ARCHIVE_DIR + source = memory_dir / memory_file + destination = archive_dir / archived_file + try: + # The layout is validated before the file is looked up, on the path both runs share: + # a symlink or a non-directory at memory/archive is refused here; an absent one is None. + archive_fd = _open_archive_dir(memory_fd, archive_dir, create=False) + try: + source_stat = _stat_regular_file(memory_file, memory_fd, source, "memory file") + _refuse_active_identity(source_stat, memory_fd, memory_dir, memory_file) + if archive_fd is not None: + _require_absent(archived_file, archive_fd, destination, "archive destination") + if not dry_run: + if archive_fd is None: + archive_fd = _open_archive_dir(memory_fd, archive_dir, create=True) + if archive_fd is None: # unreachable: create=True never returns None + raise ArchiveError(f"archive directory not found: {archive_dir}") + _move_no_replace(memory_file, memory_fd, archived_file, archive_fd, source_stat, source, destination) + finally: + if archive_fd is not None: + os.close(archive_fd) + finally: + os.close(memory_fd) + return { + "project": project, + "action": "archive", + "memory_file": memory_file, + "archived_file": archived_file, + "source": str(source), + "destination": str(destination), + "dry_run": dry_run, + "status": "dry-run" if dry_run else "archived", + } + + +def restore(home: Path, project: str, archived_file: str, *, dry_run: bool = False) -> Dict[str, Any]: + """Move ``memory/archive/`` back to ``memory/``, which must not exist.""" + _require_posix() + restored_file = original_name_from_archive(archived_file) + memory_dir, memory_fd = _open_memory_dir(home, project) + archive_dir = memory_dir / ARCHIVE_DIR + source = archive_dir / archived_file + destination = memory_dir / restored_file + try: + archive_fd = _open_archive_dir(memory_fd, archive_dir, create=False) + if archive_fd is None: + raise ArchiveError(f"memory archive directory not found: {archive_dir}") + try: + source_stat = _stat_regular_file(archived_file, archive_fd, source, "archived memory file") + _require_absent(restored_file, memory_fd, destination, "active memory destination") + if not dry_run: + _move_no_replace(archived_file, archive_fd, restored_file, memory_fd, source_stat, source, destination) + finally: + os.close(archive_fd) + finally: + os.close(memory_fd) + return { + "project": project, + "action": "restore", + "archived_file": archived_file, + "restored_file": restored_file, + "source": str(source), + "destination": str(destination), + "dry_run": dry_run, + "status": "dry-run" if dry_run else "restored", + } + + +# --- containment: directory handles ----------------------------------------- + + +def _require_posix() -> None: + if _PLATFORM != "posix": + raise ArchiveError("archive and restore are supported on POSIX systems only") + + +def _open_memory_dir(home: Path, project: str) -> Tuple[Path, int]: + """The project's memory directory as (path, handle), every directory below the home opened without following symlinks.""" + try: + layout.validate_project_name(project) + except ValueError as exc: + raise ArchiveError(str(exc)) from None + root = Path(home).expanduser().resolve() + memory_dir = layout.project_memory_dir(root, project) + project_dir = memory_dir.parent + fd = _open_dir(str(root), None, root, "home", lambda: f"home not found: {root}") + current = root + for part in memory_dir.relative_to(root).parts: + current = current / part + if current == memory_dir: + missing = f"memory directory not found: {memory_dir}" + else: + missing = f"project '{project}' not found at {project_dir}" + try: + child = _open_dir(part, fd, current, "directory", lambda: missing) + finally: + os.close(fd) + fd = child + return memory_dir, fd + + +def _open_archive_dir(memory_fd: int, archive_dir: Path, *, create: bool) -> Optional[int]: + """A handle on ``memory/archive``; ``None`` when it is absent and not to be created. + + A symlink or a non-directory at that name is refused here, on the path + both dry run and real run share, so a dry run never reports a move the + real run could not perform. + """ + created = False + while True: + try: + return os.open(ARCHIVE_DIR, _DIR_FLAGS, dir_fd=memory_fd) + except OSError as exc: + if exc.errno == errno.ENOENT and not create: + return None + if exc.errno == errno.ENOENT and not created: + try: + os.mkdir(ARCHIVE_DIR, dir_fd=memory_fd) + except FileExistsError: + pass + except OSError as mkdir_exc: + raise ArchiveError( + f"cannot create the archive directory {archive_dir}: {mkdir_exc.strerror}" + ) from None + created = True + continue + raise _open_error( + exc, ARCHIVE_DIR, memory_fd, archive_dir, "archive directory", lambda: f"archive directory not found: {archive_dir}" + ) from None + + +def _open_dir(name: str, dir_fd: Optional[int], path: Path, what: str, missing: Callable[[], str]) -> int: + try: + return os.open(name, _DIR_FLAGS, dir_fd=dir_fd) + except OSError as exc: + raise _open_error(exc, name, dir_fd, path, what, missing) from None + + +def _open_error( + exc: OSError, name: str, dir_fd: Optional[int], path: Path, what: str, missing: Callable[[], str] +) -> ArchiveError: + # Linux answers O_NOFOLLOW on a symlink with ELOOP; macOS answers O_DIRECTORY|O_NOFOLLOW with ENOTDIR. + # Either way the open refused it; the lstat only decides which refusal to name. + if exc.errno in _IS_SYMLINK or (exc.errno == errno.ENOTDIR and _is_symlink(name, dir_fd)): + return ArchiveError(f"refusing to operate through a symlink: {what} {path} is a symlink") + if exc.errno == errno.ENOTDIR: + return ArchiveError(f"{what} {path} is not a directory") + if exc.errno == errno.ENOENT: + return ArchiveError(missing()) + return ArchiveError(f"cannot open {what} {path}: {exc.strerror}") + + +def _is_symlink(name: str, dir_fd: Optional[int]) -> bool: + try: + return stat.S_ISLNK(os.stat(name, dir_fd=dir_fd, follow_symlinks=False).st_mode) + except OSError: + return False + + +def _stat_regular_file(name: str, dir_fd: int, path: Path, what: str) -> os.stat_result: + try: + st = os.stat(name, dir_fd=dir_fd, follow_symlinks=False) + except FileNotFoundError: + raise ArchiveError(f"{what} not found: {path}") from None + except OSError as exc: + raise ArchiveError(f"cannot stat {what} {path}: {exc.strerror}") from None + if stat.S_ISLNK(st.st_mode): + raise ArchiveError(f"refusing to move a symlink: {what} {path}") + if not stat.S_ISREG(st.st_mode): + raise ArchiveError(f"{what} is not a regular file: {path}") + return st + + +def _require_absent(name: str, dir_fd: int, path: Path, what: str) -> None: + try: + os.stat(name, dir_fd=dir_fd, follow_symlinks=False) + except FileNotFoundError: + return + except OSError as exc: + raise ArchiveError(f"cannot stat {what} {path}: {exc.strerror}") from None + raise ArchiveError(f"{what} already exists: {path}") + + +def _refuse_active_identity(source_stat: os.stat_result, memory_fd: int, memory_dir: Path, memory_file: str) -> None: + """Refuse a source that *is* an active file, whatever name addresses it on this filesystem.""" + for protected in PROTECTED_FILES: + try: + st = os.stat(protected, dir_fd=memory_fd, follow_symlinks=False) + except FileNotFoundError: + continue + except OSError as exc: + raise ArchiveError(f"cannot stat memory file {memory_dir / protected}: {exc.strerror}") from None + if (st.st_dev, st.st_ino) == (source_stat.st_dev, source_stat.st_ino): + raise ArchiveError( + f"cannot archive standard active memory file: {memory_file} is {protected} on this filesystem" + ) + + +# --- the move -------------------------------------------------------------- + + +def _move_no_replace( + src_name: str, + src_fd: int, + dst_name: str, + dst_fd: int, + expected: os.stat_result, + source: Path, + destination: Path, +) -> None: + """Link ``src_name`` (in ``src_fd``) as ``dst_name`` (in ``dst_fd``) without replacing anything, then unlink the source. + + Both syscalls run relative to the directory handles, so nothing re-resolves a + path after validation. The new link must be a regular file with the identity + that was checked; a symlink or a different inode planted at the source name + meanwhile is refused and the link (ours by construction) removed. Best effort + for a regular file: some filesystems reuse a freed inode number at once. The + name lies inside the validated directory either way. + """ + try: + os.link(src_name, dst_name, src_dir_fd=src_fd, dst_dir_fd=dst_fd, follow_symlinks=False) + except FileExistsError: + raise ArchiveError(f"destination already exists: {destination}") from None + except OSError as exc: + if exc.errno in _NO_HARD_LINKS: + raise ArchiveError( + f"cannot link {source} to {destination}: {exc.strerror}; " + "archive and restore need a filesystem with hard links" + ) from None + raise ArchiveError(f"cannot link {source} to {destination}: {exc.strerror}") from None + try: + linked = os.stat(dst_name, dir_fd=dst_fd, follow_symlinks=False) + except OSError as exc: + raise ArchiveError(f"cannot stat the new link {destination}: {exc.strerror}") from None + if (linked.st_dev, linked.st_ino) != (expected.st_dev, expected.st_ino) or not stat.S_ISREG(linked.st_mode): + try: + os.unlink(dst_name, dir_fd=dst_fd) + except OSError: + pass + raise ArchiveError(f"refusing to complete the move: {source} changed after it was checked") + try: + os.unlink(src_name, dir_fd=src_fd) + except OSError as exc: + raise ArchiveError( + f"archived copy created at {destination} but the source could not be removed: {source}: {exc.strerror}" + ) from None diff --git a/src/agent_memory/cli.py b/src/agent_memory/cli.py new file mode 100644 index 0000000..eaa92e6 --- /dev/null +++ b/src/agent_memory/cli.py @@ -0,0 +1,437 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Command-line interface: ``agent-memory``.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, List, Optional, Sequence, Tuple + +from . import __version__, archive, debrief, doctor, org, setup, startup, status, sync, workflow +from .home import BINDING_FILE, HomeError, HomeResolution, find_project, resolve_home + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_USAGE = 2 +#: ``setup`` found something it will not write over; the report names it. +EXIT_CONFLICT = 3 + + +def build_parser() -> argparse.ArgumentParser: + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--home", + metavar="PATH", + help="memory home (default: $AGENT_MEMORY_HOME, then $OACP_HOME, then a binding or workspace marker " + "found above the working directory, else ~/agent-memory)", + ) + agent = argparse.ArgumentParser(add_help=False) + agent.add_argument( + "--agent", + metavar="NAME", + help=f"the agent the commit is published under (default: ${sync.ENV_AGENT}, then $AGENT_NAME, then $USER)", + ) + parser = argparse.ArgumentParser( + prog="agent-memory", + description="Cross-session memory for coding agents: plain files, git-native, no server.", + allow_abbrev=False, + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + commands = parser.add_subparsers(dest="command", metavar="") + + def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str, *parents: argparse.ArgumentParser) -> argparse.ArgumentParser: + command = commands.add_parser(name, parents=[common, *parents], help=help_text, description=help_text) + command.set_defaults(handler=handler) + return command + + status_ = add( + "status", + _status, + "Show which home resolves, which rule chose it, whether the layout is in place, and where the sync stands. " + "Exit 1 when the tree is dirty or diverged.", + ) + status_.add_argument( + "--fetch", action="store_true", help="contact the remote before counting ahead/behind (no network otherwise)" + ) + doctor_ = add( + "doctor", + _doctor, + "Check the home's setup and health: the debrief store's layout and the sync repository. " + "Reads no memory content; repairs nothing. Exit 1 on an error row.", + ) + doctor_.add_argument("--json", dest="json_output", action="store_true", help="emit the report as JSON") + init_ = add( + "init", + _init, + "Create the memory home from the templates bundled in the package: the org tier, and with --project that " + "project's tier; with --repo, bind that repository to the home. No git, no network.", + ) + init_.add_argument( + "--project", metavar="ID", help="also create this project's memory tier (derived from --repo's name when omitted)" + ) + init_.add_argument( + "--repo", metavar="PATH", help=f"record a {BINDING_FILE} binding in this repository, after every other check" + ) + org_ = commands.add_parser("org", help="Org-tier commands.", description="Org-tier commands.") + org_commands = org_.add_subparsers(dest="org_command", metavar="") + org_init = org_commands.add_parser( + "init", + parents=[common], + help="Scaffold the org tier of an existing home from the bundled templates.", + description="Scaffold the org tier of an existing home from the bundled templates.", + ) + org_init.set_defaults(handler=_org_init) + enable = add( + "enable", _enable, "Make the home a sync repository: git, the managed ignore block, the marker, one commit.", agent + ) + enable.add_argument("--remote", metavar="URL", help="git remote to sync with (added or updated as 'origin')") + clone_ = add("clone", _clone, "Clone a memory repository into the home.") + clone_.add_argument("url", help="git remote URL to clone") + clone_.add_argument("--force", action="store_true", help="move a non-empty home aside before cloning") + add("pull", _pull, "Fast-forward the home from its upstream when the tree is clean and not ahead.") + add("push", _push, "Commit the allowlisted memory changes and push them when a remote exists.", agent) + add("disable", _disable, "Remove the sync marker; the repository stays in place.") + for name, handler, help_text, positional, positional_help in ( + ("archive", _archive, "Move a supplementary memory file into the project's memory/archive/.", + "memory_file", "basename of the active memory file to archive"), + ("restore", _restore, "Move an archived memory file back into the project's active memory.", + "archived_file", "basename of the archived file to restore (_)"), + ): + command = add(name, handler, help_text) + command.add_argument("project", help="project name under projects/") + command.add_argument(positional, help=positional_help) + command.add_argument("--dry-run", action="store_true", help="perform every check and report; move nothing") + command.add_argument("--json", dest="json_output", action="store_true", help="emit the result as JSON") + setup_ = add( + "setup", + _setup, + "Install the runtime's session-start memory hook in a repository: the script, its registration, a receipt " + "in the home; retire the legacy hooks by exact match. Never writes over an edited file or through a symlink " + "(exit 3 names what it kept). No push hook, ever.", + ) + setup_.add_argument("runtime", choices=setup.RUNTIMES, help="the runtime whose hook to install") + setup_.add_argument("--repo", metavar="PATH", help="the repository (default: the nearest .git above the working directory)") + setup_.add_argument("--dry-run", action="store_true", help="print the plan; write nothing") + setup_.add_argument("--json", dest="json_output", action="store_true", help="emit the plan or result as JSON") + startup_ = add( + "startup", + _startup, + "Print the session-start manifest: the memory files to read, in order, with their readability, size and " + "age, and where the sync stands. Content is never included. With --pull, fast-forward the home first.", + ) + startup_.add_argument("--runtime", choices=startup.RUNTIMES, required=True, help="shape the output for this runtime's hook") + startup_.add_argument("--project", metavar="ID", help="the project tier to list (default: the one the binding or marker names)") + startup_.add_argument("--pull", action="store_true", help="pull the home before listing; a failed pull is a warning") + startup_.add_argument("--json", dest="json_output", action="store_true", help="emit the manifest as JSON") + startup_.add_argument( + "--max-chars", + type=_max_chars, + default=startup.DEFAULT_MAX_CHARS, + metavar="N", + help=f"cut the rendered text, its notice included, at N characters (at least {startup.MIN_MAX_CHARS})", + ) + capture_ = add( + "capture", + _capture, + "Record one decision in the project's decision_log.md, newest first under today's UTC date, with its " + "provenance (agent, time, source). The project comes from --project, else from the repository's binding or " + "workspace marker.", + ) + capture_.add_argument("decision", help="the decision, one sentence") + capture_.add_argument("--why", metavar="TEXT", help="the reason, one sentence") + capture_.add_argument("--source", metavar="REF", help="where it was decided: a PR, an issue, a message") + capture_.add_argument( + "--agent", metavar="NAME", help=f"who is capturing (default: ${workflow.DEFAULT_AGENT_ENV}, else the user)" + ) + capture_.add_argument("--project", metavar="ID", help="the project whose log to write (default: the bound one)") + capture_.add_argument("--dry-run", action="store_true", help="compose the entry and report; write nothing") + capture_.add_argument("--json", dest="json_output", action="store_true", help="emit the result as JSON") + recall_ = add( + "recall", + _recall, + "Print the memory files the startup manifest lists, in its order, with their content, cut at a character " + "budget: the bounded read at session start. archive/, events/ and debriefs/ are never loaded.", + ) + recall_.add_argument("--project", metavar="ID", help="the project tier to read (default: the bound one)") + recall_.add_argument( + "--runtime", + choices=startup.RUNTIMES, + help=f"the runtime reading (default: ${workflow.DEFAULT_AGENT_ENV} when it names one, else {startup.RUNTIME_CLAUDE})", + ) + recall_.add_argument( + "--max-chars", + type=_max_chars, + default=startup.DEFAULT_MAX_CHARS, + metavar="N", + help=f"cut the text, its notice included, at N characters (at least {startup.MIN_MAX_CHARS})", + ) + recall_.add_argument("--json", dest="json_output", action="store_true", help="emit the result as JSON") + debrief_ = commands.add_parser("debrief", help="Debrief-store commands.", description="Debrief-store commands.") + debrief_commands = debrief_.add_subparsers(dest="debrief_command", metavar="") + write_help = ( + "Publish one session debrief into the home's debrief store, failure-atomically: the canonical path only ever " + "holds a complete, verified record. Exit 0 published (or an identical record was already there), 1 on a " + "validation error, 2 on a publication failure." + ) + write = debrief_commands.add_parser("write", parents=[common], help=write_help, description=write_help) + write.set_defaults(handler=_debrief_write) + write.add_argument("--project", required=True, help="workspace project name") + write.add_argument("--agent", required=True, help="writing agent name") + write.add_argument("--runtime", required=True, help="runtime family (claude, codex, ...)") + write.add_argument("--session", required=True, help="short session id: 1-32 lowercase alphanumerics, no hyphens") + write.add_argument("--started-utc", required=True, help="session start, ISO 8601 UTC (Z)") + write.add_argument("--ended-utc", required=True, help="session end, ISO 8601 UTC (Z)") + write.add_argument("--body-file", required=True, help="path to the debrief body in Markdown, or '-' to read stdin") + # The name the writer carried before it became this verb; accepted, unadvertised, through v0.1.x. + write.add_argument("--oacp-dir", dest="home", metavar="PATH", help=argparse.SUPPRESS) + write.add_argument("--dry-run", action="store_true", help="validate and compose the record, print it, and write nothing") + write.add_argument("--json", dest="json_output", action="store_true", help="emit a machine-readable result") + return parser + + +def _max_chars(value: str) -> int: + try: + number = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"expected an integer, got {value!r}") from exc + if number < startup.MIN_MAX_CHARS: + raise argparse.ArgumentTypeError(f"must be at least {startup.MIN_MAX_CHARS}, got {number}") + return number + + +def _status(args: argparse.Namespace) -> int: + readout = status.inspect(resolve_home(args.home), fetch=args.fetch) + print("\n".join(readout.lines())) + return readout.exit_code + + +def _doctor(args: argparse.Namespace) -> int: + home = resolve_home(args.home).path + if not home.is_dir(): + print(f"agent-memory: error: {home} is not a directory", file=sys.stderr) + return EXIT_FAILED + categories = doctor.run_doctor(home) + memory_lint = doctor.find_memory_lint() + if args.json_output: + print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint), indent=2)) + else: + sys.stdout.write(doctor.report(categories, memory_lint=memory_lint)) + return EXIT_FAILED if doctor.has_errors(categories) else EXIT_OK + + +def _init(args: argparse.Namespace) -> int: + repo = Path(args.repo) if args.repo else None + report = org.init(resolve_home(args.home).path, project=args.project, repo=repo) + print("\n".join(report.lines())) + return EXIT_OK + + +def _org_init(args: argparse.Namespace) -> int: + print("\n".join(org.org_init(resolve_home(args.home).path).lines())) + return EXIT_OK + + +def _enable(args: argparse.Namespace) -> int: + return _report(sync.init(resolve_home(args.home).path, remote=args.remote, agent=args.agent)) + + +def _clone(args: argparse.Namespace) -> int: + return _report(sync.clone(resolve_home(args.home).path, args.url, force=args.force)) + + +def _pull(args: argparse.Namespace) -> int: + return _report(sync.pull(resolve_home(args.home).path)) + + +def _push(args: argparse.Namespace) -> int: + return _report(sync.push(resolve_home(args.home).path, agent=args.agent)) + + +def _disable(args: argparse.Namespace) -> int: + return _report(sync.disable(resolve_home(args.home).path)) + + +def _archive(args: argparse.Namespace) -> int: + result = archive.archive(resolve_home(args.home).path, args.project, args.memory_file, dry_run=args.dry_run) + if args.json_output: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + verb = "Would archive" if args.dry_run else "Archived" + print(f"{verb} memory/{result['memory_file']} -> memory/{archive.ARCHIVE_DIR}/{result['archived_file']}") + return EXIT_OK + + +def _restore(args: argparse.Namespace) -> int: + result = archive.restore(resolve_home(args.home).path, args.project, args.archived_file, dry_run=args.dry_run) + if args.json_output: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + verb = "Would restore" if args.dry_run else "Restored" + print(f"{verb} memory/{archive.ARCHIVE_DIR}/{result['archived_file']} -> memory/{result['restored_file']}") + return EXIT_OK + + +def _setup(args: argparse.Namespace) -> int: + spec = setup.SPECS[args.runtime] + repo = Path(args.repo) if args.repo else setup.detect_repo(Path.cwd()) + result = setup.run_setup(spec, repo, resolve_home(args.home).path, dry_run=args.dry_run) + if args.json_output: + print(json.dumps(setup.to_json(result), indent=2)) + else: + print("\n".join(setup.lines(result))) + return EXIT_CONFLICT if result.plan.conflicts else EXIT_OK + + +def _resolve_project(flag: Optional[str], resolution: HomeResolution) -> Tuple[Optional[str], Optional[str], List[str]]: + """``(project, source, notes)``: the flag, else what chose the home, else the repository's binding or marker.""" + if flag: + return flag, "flag", [] + if resolution.project: + return resolution.project, resolution.source, [] + # A flag or an environment variable chose the home; the repository's binding or marker still names the project. + found = find_project(resolution.path, Path.cwd()) + return found.project, found.source, [found.note] if found.note else [] + + +def _capture(args: argparse.Namespace) -> int: + resolution = resolve_home(args.home) + project, _, notes = _resolve_project(args.project, resolution) + if project is None: + for note in notes: + print(f"agent-memory: {note}", file=sys.stderr) + print( + "agent-memory: error: no project resolved; pass --project or bind the repository with `agent-memory init --repo .`", + file=sys.stderr, + ) + return EXIT_USAGE + result = workflow.capture( + resolution.path, project, args.decision, why=args.why, source=args.source, agent=args.agent, dry_run=args.dry_run + ) + if args.json_output: + print(json.dumps(result, indent=2)) + else: + verb = "would capture" if args.dry_run else "captured" + print(f"{verb}: {result['path']} (## {result['date']})") + print(result["entry"]) + return EXIT_OK + + +def _recall(args: argparse.Namespace) -> int: + resolution = resolve_home(args.home) + project, project_source, notes = _resolve_project(args.project, resolution) + runtime = args.runtime or workflow.default_runtime() + result = workflow.recall( + resolution.path, + project=project, + runtime=runtime, + max_chars=args.max_chars, + home_source=resolution.source, + project_source=project_source, + notes=notes, + ) + if args.json_output: + print(json.dumps(result, indent=2)) + else: + sys.stdout.write(result["text"]) + return EXIT_OK + + +def _startup(args: argparse.Namespace) -> int: + resolution = resolve_home(args.home) + project, project_source, notes = _resolve_project(args.project, resolution) + manifest = startup.build_manifest( + resolution.path, + runtime=args.runtime, + project=project, + pull=args.pull, + home_source=resolution.source, + project_source=project_source, + notes=notes, + ) + if args.json_output: + print(json.dumps(manifest, indent=2)) + elif args.runtime == startup.RUNTIME_CODEX: + print(json.dumps(startup.render_codex_hook(manifest, max_chars=args.max_chars))) + else: + sys.stdout.write(startup.render_text(manifest, max_chars=args.max_chars)) + return EXIT_OK + + +def _debrief_write(args: argparse.Namespace) -> int: + home = resolve_home(args.home).path + try: + if args.body_file == "-": + body = sys.stdin.buffer.read() + else: + body = Path(args.body_file).expanduser().read_bytes() + except OSError as exc: + print(f"ERROR: cannot read body: {exc}", file=sys.stderr) + return EXIT_FAILED + + try: + result = debrief.write_debrief( + home=home, + project=args.project, + agent=args.agent, + runtime=args.runtime, + session=args.session, + started_utc=args.started_utc, + ended_utc=args.ended_utc, + body=body, + dry_run=args.dry_run, + ) + except debrief.WriterError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return exc.code + except OSError as exc: + print(f"ERROR: publication failed: {exc}", file=sys.stderr) + return 2 + + if args.json_output: + print( + json.dumps( + { + "path": str(result.path), + "status": result.status, + "content_sha256": result.content_sha256, + "schema_version": debrief.SCHEMA_VERSION, + }, + indent=2, + ) + ) + else: + print(f"{result.status}: {result.path}") + print(f"content_sha256: {result.content_sha256}") + + if args.dry_run: + print("--- record preview (nothing was written) ---", file=sys.stderr) + sys.stderr.flush() + sys.stderr.buffer.write(result.record) + sys.stderr.buffer.flush() + return EXIT_OK + + +def _report(outcome: sync.Outcome) -> int: + if outcome.lines: + print("\n".join(outcome.lines), file=sys.stdout if outcome.ok else sys.stderr) + return EXIT_OK if outcome.ok else EXIT_FAILED + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + handler = getattr(args, "handler", None) + if handler is None: + parser.print_help(sys.stderr) + return EXIT_USAGE + try: + return handler(args) + except HomeError as exc: + print(f"agent-memory: error: {exc}", file=sys.stderr) + return EXIT_USAGE + except (sync.SyncError, archive.ArchiveError, org.ScaffoldError, setup.SetupError, workflow.WorkflowError) as exc: + print(f"agent-memory: error: {exc}", file=sys.stderr) + return EXIT_FAILED diff --git a/src/agent_memory/debrief.py b/src/agent_memory/debrief.py new file mode 100644 index 0000000..61d8bca --- /dev/null +++ b/src/agent_memory/debrief.py @@ -0,0 +1,336 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Publish a session debrief into the debrief store: ``agent-memory debrief write``. + +Implements the writer contract of the memory layout spec (the kernel's +``docs/protocol/org_memory.md`` -> "Debrief Store"). The layout and schema are +the spec's; everything here -- schema completeness, the content hash, and +failure-atomic publication through :mod:`agent_memory.publication` -- is the +writer's responsibility. + +Canonical path:: + + /org-memory/debriefs////--.md + +Exit codes of the verb: + 0 published (or idempotent re-publish of a byte-identical record) + 1 usage / validation error + 2 publication failure (collision, read-back mismatch, hostile target) +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import re +from pathlib import Path +from typing import Dict, NamedTuple, Tuple + +from . import layout +from .publication import STAGE_PREFIX, WriterError, publish, staging_path # noqa: F401 (re-exported) + +SCHEMA_VERSION = 1 + +# Mirrors the protocol's canonical agent-name rule; hyphens, dots, +# underscores and mixed case are all representable in the agent segment. +AGENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + +# The session identifier is the substring after the FINAL hyphen, so it must +# never contain one -- that is what keeps the three-part filename uniquely +# parseable for any valid agent name. +SESSION_RE = re.compile(r"^[a-z0-9]{1,32}$") + +FRONTMATTER_DELIM = b"---\n" + +REQUIRED_FRONTMATTER_ORDER = ( + "schema_version", + "project", + "agent", + "runtime", + "session", + "started_utc", + "ended_utc", + "content_sha256", + "immutable", +) + +# A control character in any identity field would break out of the frontmatter +# block it is serialized into and corrupt the path segment it names, so every +# identity value is screened for them before composition. +CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]") + +# Runtime family names follow the same shape as agent names. +RUNTIME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + +STATUS_DRY_RUN = "dry-run" + + +class DebriefResult(NamedTuple): + """Outcome of one debrief write.""" + + path: Path + status: str + content_sha256: str + record: bytes + + +# -------------------------------------------------------------------------- +# validation +# -------------------------------------------------------------------------- + + +def valid_project_segment(name: str) -> bool: + """Workspace project-name rule: no leading dot, no path separators. + + Control characters are rejected on top of the protocol rule: they cannot + appear in a usable path segment, and a newline would inject extra lines + into the frontmatter block the name is serialized into. + """ + return ( + bool(name) + and not name.startswith(".") + and "/" not in name + and "\\" not in name + and not CONTROL_CHARS_RE.search(name) + ) + + +def parse_utc(label: str, value: str) -> dt.datetime: + """Parse an ISO 8601 UTC timestamp that ends in ``Z``.""" + if not value.endswith("Z"): + raise WriterError(f"{label} must be ISO 8601 UTC ending in 'Z': {value!r}", 1) + try: + parsed = dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise WriterError(f"{label} is not a valid UTC timestamp: {value!r} ({exc})", 1) from exc + return parsed.replace(tzinfo=dt.timezone.utc) + + +def validate_identity(project: str, agent: str, runtime: str, session: str) -> None: + if not valid_project_segment(project): + raise WriterError( + f"project {project!r} is not a valid workspace name (must not start with '.' or contain '/' or '\\')", + 1, + ) + if not AGENT_RE.match(agent): + raise WriterError(f"agent {agent!r} does not match the protocol agent-name rule {AGENT_RE.pattern}", 1) + if not SESSION_RE.match(session): + raise WriterError( + f"session {session!r} must be 1-32 lowercase letters/digits with no " + "hyphens (the identifier is parsed as the substring after the final hyphen)", + 1, + ) + if not RUNTIME_RE.match(runtime): + raise WriterError(f"runtime {runtime!r} must match {RUNTIME_RE.pattern}", 1) + + +def validate_body(body: bytes) -> None: + """The record is a Markdown file, so the body must be valid UTF-8. + + Checked before the store is touched: a record whose body cannot be decoded + is unreadable to every consumer, and the post-publication read-back cannot + catch it because it compares the file against the same bytes that composed + it. + """ + if not body: + raise WriterError("refusing to publish a debrief with an empty body", 1) + try: + body.decode("utf-8") + except UnicodeDecodeError as exc: + raise WriterError(f"debrief body is not valid UTF-8 at byte {exc.start}: {exc.reason}", 1) from exc + + +# -------------------------------------------------------------------------- +# record composition +# -------------------------------------------------------------------------- + + +def content_sha256(body: bytes) -> str: + """Lowercase-hex SHA-256 over the exact body bytes -- no normalization.""" + return hashlib.sha256(body).hexdigest() + + +def _yaml_scalar(value: object) -> str: + """Serialize one frontmatter value. + + ``schema_version`` is an integer and ``immutable`` a boolean; every other + field is a string, and strings are emitted in YAML single-quoted style + unconditionally. Conditional quoting is not safe here: identifiers the + protocol grammar accepts -- ``true``, ``null``, ``no``, ``on``, ``y`` -- + are plain-scalar keywords a YAML reader re-types, silently changing the + record's identity, and leading indicators such as ``*`` or ``&`` produce a + record no parser will read at all. Single-quoted style preserves any + control-character-free string exactly, escaping an embedded quote by + doubling it. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + text = str(value) + if not text or text != text.strip(): + raise WriterError(f"refusing to emit an untrimmed/empty frontmatter scalar: {text!r}") + if CONTROL_CHARS_RE.search(text): + # Defense in depth: identity fields are screened before composition, so + # reaching here means a caller bypassed validation. + raise WriterError(f"refusing to emit a frontmatter scalar with control characters: {text!r}") + return "'" + text.replace("'", "''") + "'" + + +def compose_record( + *, + project: str, + agent: str, + runtime: str, + session: str, + started_utc: str, + ended_utc: str, + body: bytes, +) -> bytes: + """Build the full record: frontmatter block + verbatim body bytes.""" + fields: Dict[str, object] = { + "schema_version": SCHEMA_VERSION, + "project": project, + "agent": agent, + "runtime": runtime, + "session": session, + "started_utc": started_utc, + "ended_utc": ended_utc, + "content_sha256": content_sha256(body), + "immutable": True, + } + lines = [f"{key}: {_yaml_scalar(fields[key])}" for key in REQUIRED_FRONTMATTER_ORDER] + head = FRONTMATTER_DELIM + ("\n".join(lines) + "\n").encode("utf-8") + FRONTMATTER_DELIM + record = head + body + _assert_record_roundtrips(record, fields, body) + return record + + +def _assert_record_roundtrips(record: bytes, fields: Dict[str, object], body: bytes) -> None: + """Re-parse the composed record and assert it says what it was asked to say. + + Composition is the one step that can silently change a record's identity, + and nothing downstream can catch it: the doctor never opens debrief files, + and the post-publication read-back compares the stored file against these + same composed bytes. So the writer closes the loop itself, here, before the + store is touched. + """ + parsed, parsed_body = split_record(record) + if list(parsed) != list(REQUIRED_FRONTMATTER_ORDER): + raise WriterError( + f"composed frontmatter does not carry exactly the required fields in canonical order: {list(parsed)}" + ) + for key, expected in fields.items(): + if isinstance(expected, bool): + want = "true" if expected else "false" + elif isinstance(expected, int): + want = str(expected) + else: + want = str(expected) + if parsed[key] != want: + raise WriterError(f"composed frontmatter field {key!r} did not round-trip: {parsed[key]!r} != {want!r}") + if parsed_body != body: + raise WriterError("composed record body did not round-trip byte-for-byte") + + +def split_record(raw: bytes) -> Tuple[Dict[str, str], bytes]: + """Split a stored record into (frontmatter mapping, body bytes). + + The body is every byte after the line that closes the frontmatter block -- + the second ``---`` line including its trailing newline -- exactly as + stored. This is the definition the ``content_sha256`` field is computed + over, so it must not normalize anything. + """ + if not raw.startswith(FRONTMATTER_DELIM): + raise WriterError("record does not begin with a '---' frontmatter delimiter") + rest = raw[len(FRONTMATTER_DELIM) :] + end = rest.find(b"\n" + FRONTMATTER_DELIM) + if end == -1: + raise WriterError("record frontmatter block is not closed by a '---' line") + head = rest[:end] + body = rest[end + 1 + len(FRONTMATTER_DELIM) :] + + frontmatter: Dict[str, str] = {} + for line in head.decode("utf-8").splitlines(): + if not line.strip(): + continue + key, _, value = line.partition(":") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + quote = value[0] + value = value[1:-1] + if quote == "'": + # Undo YAML single-quoted escaping. + value = value.replace("''", "'") + frontmatter[key.strip()] = value + return frontmatter, body + + +def verify_record(raw: bytes) -> None: + """The consistency check publication runs over staged and read-back bytes: + the body must hash to the ``content_sha256`` the frontmatter declares.""" + frontmatter, body = split_record(raw) + if content_sha256(body) != frontmatter.get("content_sha256"): + raise WriterError("body does not match content_sha256") + + +def canonical_name(started: dt.datetime, agent: str, session: str) -> str: + return f"{started.strftime('%Y%m%d')}-{agent}-{session}.md" + + +def canonical_path(home: Path, project: str, started: dt.datetime, agent: str, session: str) -> Path: + return ( + layout.org_memory_dir(home) + / "debriefs" + / project + / started.strftime("%Y") + / started.strftime("%m") + / canonical_name(started, agent, session) + ) + + +# -------------------------------------------------------------------------- +# the verb +# -------------------------------------------------------------------------- + + +def write_debrief( + *, + home: Path, + project: str, + agent: str, + runtime: str, + session: str, + started_utc: str, + ended_utc: str, + body: bytes, + dry_run: bool = False, +) -> DebriefResult: + """Validate, compose and publish one debrief. + + With ``dry_run`` the record is validated and composed exactly as it would + be published, and the store is not touched -- no directories created, no + files written. The status is then ``dry-run``. + """ + validate_identity(project, agent, runtime, session) + started = parse_utc("started_utc", started_utc) + ended = parse_utc("ended_utc", ended_utc) + if ended < started: + raise WriterError(f"ended_utc ({ended_utc}) is before started_utc ({started_utc})", 1) + validate_body(body) + + record = compose_record( + project=project, + agent=agent, + runtime=runtime, + session=session, + started_utc=started_utc, + ended_utc=ended_utc, + body=body, + ) + target = canonical_path(home, project, started, agent, session) + digest = content_sha256(body) + if dry_run: + return DebriefResult(target, STATUS_DRY_RUN, digest, record) + status = publish(target, record, verify=verify_record) + return DebriefResult(target, status, digest, record) diff --git a/src/agent_memory/doctor.py b/src/agent_memory/doctor.py new file mode 100644 index 0000000..9a69eae --- /dev/null +++ b/src/agent_memory/doctor.py @@ -0,0 +1,710 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Setup and health checks for a memory home: ``agent-memory doctor``. + +Two categories, ported row for row from the 0.4.5 memory doctor (the golden +under ``tests/golden/``): + +* **Org Memory** validates the debrief store's layout: the directory exists, + every record sits at ``///--.md``, + no writer staging artifact lingers, and nothing under the store is a symlink + or otherwise irregular. It never opens a record, and a traversal it cannot + complete is its own error row, never a clean result. +* **Memory Sync** reads the sync marker, the root ``.gitignore``, and what git + reports about the home: tracked paths against the allowlist, untracked + memory-shaped files, the working tree, the upstream, the remote, the last + commit's age, per-instance ``agents/`` state, and the project ``.gitignore`` + overlays. A git command that fails produces a warning row for its check, + never a pass. + +The doctor reads no memory content and repairs nothing: a row that is not ok +carries a hint for the human, and the home is byte-identical after a run. Every +filesystem probe distinguishes a path that is absent from one it was denied, +so nothing unreadable reads as absent, and nothing absent reads as fine. The +result frame here is local to this module. +""" + +from __future__ import annotations + +import datetime as dt +import os +import re +import shutil +import stat +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +from . import layout, sync +from .git_runner import GitResult, GitRunner, run_git +from .sync import GitState + +#: A last commit older than this many days is reported stale. +STALE_MEMORY_DAYS = 7 + +#: The content checker the doctor points at when it is installed; content is its job. +MEMORY_LINT = "memory-lint" + +# The agent segment is the canonical agent grammar; the session segment is +# hyphen-free, so the split on the last hyphen is deterministic. +_AGENT = r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}" +DEBRIEF_FILENAME_RE = re.compile(rf"^(?P\d{{8}})-(?P{_AGENT})-(?P[a-z0-9]{{1,32}})\.md$") + + +# --- the result frame ------------------------------------------------------- + + +class Severity(Enum): + ok = "ok" + warn = "warn" + error = "error" + skip = "skip" + + +SYMBOL = { + Severity.ok: "[+]", + Severity.warn: "[!]", + Severity.error: "[x]", + Severity.skip: "[-]", +} + + +@dataclass(frozen=True) +class Result: + """One row: a stable name, a severity, the message, and a hint when it is not ok.""" + + name: str + severity: Severity + message: str + fix_hint: str = "" + + +@dataclass +class Category: + """One block of rows under a heading.""" + + name: str + results: List[Result] = field(default_factory=list) + + @property + def worst_severity(self) -> Severity: + for severity in (Severity.error, Severity.warn, Severity.skip, Severity.ok): + if any(result.severity is severity for result in self.results): + return severity + return Severity.ok + + def add(self, name: str, severity: Severity, message: str, fix_hint: str = "") -> None: + self.results.append(Result(name, severity, message, fix_hint)) + + +def has_errors(categories: Sequence[Category]) -> bool: + return any(result.severity is Severity.error for category in categories for result in category.results) + + +# --- running ---------------------------------------------------------------- + + +def run_doctor( + home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None +) -> List[Category]: + """Both memory categories for ``home``, in report order.""" + return [check_org_memory(home), check_memory_sync(home, runner=runner, now=now)] + + +def find_memory_lint(which: Callable[[str], Optional[str]] = shutil.which) -> Optional[str]: + """Where ``memory-lint`` is on PATH, or ``None``.""" + return which(MEMORY_LINT) + + +def report(categories: Sequence[Category], *, memory_lint: Optional[str] = None) -> str: + """The text report: one block per category, a verdict line, and the pointer to memory-lint when it is installed.""" + lines: List[str] = [] + for index, category in enumerate(categories): + if index: + lines.append("") + lines.append(f"{SYMBOL[category.worst_severity]} {category.name}") + for result in category.results: + lines.append(f" {SYMBOL[result.severity]} {result.message}") + if result.fix_hint and result.severity is not Severity.ok: + lines.append(f" {result.fix_hint}") + lines.append("") + lines.append("Doctor found issues that need attention." if has_errors(categories) else "No issues found.") + if memory_lint: + lines.append(f"{MEMORY_LINT} is installed at {memory_lint}; content checks (links, index rows, staleness) are its job.") + return "\n".join(lines) + "\n" + + +def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None) -> Dict[str, Any]: + """The report as data, in the same order as the text.""" + output: Dict[str, Any] = { + "has_errors": has_errors(categories), + "memory_lint": memory_lint, + "categories": [], + } + for category in categories: + rows: List[Dict[str, str]] = [] + for result in category.results: + row = {"name": result.name, "severity": result.severity.value, "message": result.message} + if result.fix_hint: + row["fix_hint"] = result.fix_hint + rows.append(row) + output["categories"].append( + {"name": category.name, "worst_severity": category.worst_severity.value, "results": rows} + ) + return output + + +# --- Org Memory: the debrief store's layout --------------------------------- +# +# Setup-level by design: the doctor confirms the store exists, the path layout +# is canonical, and nothing irregular sits in the namespace. It never opens a +# record; content and format belong to the writer's read-back at publication +# and to git history. A failed traversal or classification produces its own +# non-ok row, never a clean result. + + +def check_org_memory(home: Path) -> Category: + """The debrief store under ``org-memory/``: presence, canonical layout, staging leftovers, irregular entries.""" + cat = Category("Org Memory") + org_memory = layout.org_memory_dir(home) + try: + initialized = _is_dir(org_memory) + except OSError as exc: + cat.add("org-memory-dir", Severity.error, f"{layout.ORG.pattern}/ — {_not_inspected(exc)}") + return cat + if not initialized: + cat.add("org-memory-dir", Severity.skip, f"{layout.ORG.pattern}/ — not initialized", "Run: agent-memory org init") + return cat + + debriefs = org_memory / "debriefs" + try: + present = _is_dir(debriefs) + except OSError as exc: + cat.add("debriefs-dir", Severity.error, f"{layout.ORG.pattern}/debriefs/ — {_not_inspected(exc)}") + return cat + if not present: + cat.add( + "debriefs-dir", + Severity.warn, + f"{layout.ORG.pattern}/debriefs/ — missing (pre-debrief-store layout)", + "Run: agent-memory org init", + ) + return cat + cat.add("debriefs-dir", Severity.ok, f"{layout.ORG.pattern}/debriefs/ — present") + + layout_bad: List[str] = [] + staging: List[str] = [] + irregular: List[str] = [] + walk_errors: List[str] = [] + total = 0 + + def _relative(path: Path) -> str: + try: + return path.relative_to(debriefs).as_posix() or "." + except ValueError: + return str(path) + + def _walk_error(exc: OSError) -> None: + # A directory the walk cannot enter hides an unknown number of records; + # the failure surfaces as its own row. + location = getattr(exc, "filename", None) or str(debriefs) + walk_errors.append(f"{_relative(Path(location))}: {exc.__class__.__name__}") + + entries: List[Path] = [] + # followlinks=False, so a symlinked directory cannot pull a foreign tree into + # the store; the link itself is flagged below. + for dirpath, dirnames, filenames in os.walk(debriefs, onerror=_walk_error, followlinks=False): + current = Path(dirpath) + kept: List[str] = [] + for name in sorted(dirnames): + entry = current / name + try: + is_link = entry.is_symlink() + except OSError as exc: + walk_errors.append(f"{_relative(entry)}: {exc.__class__.__name__}") + continue + if is_link: + irregular.append(f"{_relative(entry)}/ (symlinked directory)") + else: + kept.append(name) + dirnames[:] = kept + entries.extend(current / name for name in filenames) + + for file_path in sorted(entries): + rel = _relative(file_path) + if rel == ".gitkeep": + continue + # Writer staging artifacts (.stage..) sit outside the + # canonical namespace; a lingering one means an interrupted publication. + if file_path.name.startswith(".stage."): + staging.append(rel) + continue + # The namespace holds regular files reached without following links; + # a classification failure surfaces, never raises. + try: + if file_path.is_symlink(): + irregular.append(f"{rel} (symlink)") + continue + regular = file_path.is_file() + except OSError as exc: + walk_errors.append(f"{rel}: {exc.__class__.__name__}") + continue + if not regular: + irregular.append(f"{rel} (not a regular file)") + continue + total += 1 + parts = rel.split("/") + match = DEBRIEF_FILENAME_RE.match(parts[-1]) if len(parts) == 4 else None + date_valid = False + if match is not None: + try: + dt.datetime.strptime(match.group("date"), "%Y%m%d") + date_valid = True + except ValueError: + pass + if ( + match is None + or not date_valid + or not _valid_project_segment(parts[0]) + or parts[1] != match.group("date")[0:4] + or parts[2] != match.group("date")[4:6] + ): + layout_bad.append(rel) + + if staging: + cat.add( + "debriefs-staging", + Severity.warn, + f"{len(staging)} lingering writer staging artifact(s) (interrupted publication): {_summarize(staging)}", + "The owning writer removes or adopts its stale staging files on retry", + ) + if irregular: + cat.add( + "debriefs-irregular", + Severity.error, + f"{len(irregular)} non-regular entr(ies) under debriefs/ " + f"(the store holds regular files, never symlinks): {_summarize(irregular)}", + ) + if walk_errors: + cat.add( + "debriefs-unreadable", + Severity.error, + f"{len(walk_errors)} entr(ies) under debriefs/ could not be inspected " + f"(setup check incomplete): {_summarize(walk_errors)}", + ) + if total == 0: + if not walk_errors: + cat.add("debriefs-layout", Severity.ok, "debriefs/ — empty store, nothing to validate") + return cat + if layout_bad: + cat.add( + "debriefs-layout", + Severity.error, + f"{len(layout_bad)} of {total} debrief file(s) outside the canonical " + f"///--.md layout: {_summarize(layout_bad)}", + "Move or rename to the canonical path; never rewrite contents", + ) + else: + cat.add("debriefs-layout", Severity.ok, f"{total} debrief file(s) — canonical layout") + return cat + + +def _valid_project_segment(name: str) -> bool: + try: + layout.validate_project_name(name) + except ValueError: + return False + return True + + +# --- Memory Sync: the marker, the allowlist, and the repository ------------ + + +def check_memory_sync( + home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None +) -> Category: + """The sync setup and the repository's state, through git alone; nothing is changed.""" + cat = Category("Memory Sync") + marker = layout.MARKER_FILE + + try: + configured = _is_file(sync.marker_path(home)) + except OSError as exc: + cat.add("memory-marker", Severity.warn, f"{marker} — {_not_inspected(exc)}") + return cat + if not configured: + cat.add( + "memory-marker", + Severity.skip, + f"{marker} — not configured; memory sync hooks are disabled", + "Run: agent-memory enable [--remote URL]", + ) + return cat + cat.add("memory-marker", Severity.ok, f"{marker} — present") + + if not sync.is_git_repo(home, runner): + cat.add( + "memory-git", + Severity.warn, + f"{marker} — present, but {home} is not a git repository", + "Run `agent-memory enable`, or `agent-memory disable` to remove the marker", + ) + return cat + enclosing = enclosing_repository(home, runner) + if enclosing is not None: + # The sync verbs refuse this home; reading the enclosing repository's state as the home's would be wrong. + cat.add( + "memory-git", + Severity.warn, + f"{marker} — present, but {home} is inside the git worktree {enclosing}; " + "a memory home must be the root of its own repository", + "Move the home out of the enclosing repository, or `agent-memory disable` to remove the marker", + ) + return cat + + root_gitignore = home / layout.GITIGNORE_FILE + try: + present = _is_file(root_gitignore) + except OSError as exc: + cat.add("root-gitignore", Severity.warn, f"{layout.GITIGNORE_FILE} — {_not_inspected(exc)}") + present = None + if present is False: + cat.add( + "root-gitignore", + Severity.warn, + f"{layout.GITIGNORE_FILE} — missing canonical memory allowlist", + "Run: agent-memory enable", + ) + elif present: + try: + content = _normalize_gitignore(root_gitignore.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as exc: + cat.add( + "root-gitignore", + Severity.warn, + f"{layout.GITIGNORE_FILE} — could not be read: {_failure_text(exc)}", + _read_hint(exc), + ) + else: + if content == layout.gitignore_text(): + cat.add("root-gitignore", Severity.ok, f"{layout.GITIGNORE_FILE} — canonical memory allowlist") + elif sync.gitignore_has_managed_block(content): + kept = len(content.splitlines()) - len(layout.gitignore_text().splitlines()) + cat.add( + "root-gitignore", + Severity.ok, + f"{layout.GITIGNORE_FILE} — canonical memory allowlist as a managed block; {kept} other line(s) kept", + ) + else: + cat.add( + "root-gitignore", + Severity.warn, + f"{layout.GITIGNORE_FILE} — drifted from canonical memory allowlist", + "Run `agent-memory enable` to restore the managed allowlist block", + ) + + tracked: Optional[List[str]] = None + try: + tracked = _ls_files(home, runner) + outside = [path for path in tracked if not layout.is_allowed_memory_path(path)] + except _GitFailed as exc: + cat.add("tracked-allowlist", Severity.warn, f"tracked allowlist check failed: {exc}") + else: + if outside: + cat.add( + "tracked-allowlist", + Severity.warn, + f"{len(outside)} tracked file(s) outside memory allowlist: {_summarize(outside)}", + "Remove runtime state from the memory repo index", + ) + else: + cat.add("tracked-allowlist", Severity.ok, f"tracked files — {len(tracked)} inside memory allowlist") + + try: + untracked = [path for path in _ls_files(home, runner, "--others", "--exclude-standard") if layout.is_allowed_memory_path(path)] + except _GitFailed as exc: + cat.add("untracked-memory", Severity.warn, f"untracked memory check failed: {exc}") + else: + if untracked: + cat.add( + "untracked-memory", + Severity.warn, + f"{len(untracked)} untracked memory-shaped file(s): {_summarize(untracked)}", + "Run: agent-memory push", + ) + else: + cat.add("untracked-memory", Severity.ok, "untracked memory files — none") + + state: Optional[GitState] + try: + state = sync.git_state(home, runner=runner, fetch=True) + except sync.SyncError as exc: + cat.add("working-tree", Severity.warn, f"memory git state check failed: {exc}") + state = None + + if state is not None: + if state.dirty: + cat.add( + "working-tree", + Severity.warn, + "working tree — DIRTY memory changes present", + "Run `agent-memory push` or resolve changes manually", + ) + else: + cat.add("working-tree", Severity.ok, "working tree — clean") + + text = f"sync state — {sync_state_text(state)}" + if not state.has_remote: + cat.add("sync-state", Severity.ok, text) + cat.add("remote", Severity.skip, "remote — skipped; local-only memory repo") + elif state.fetch_failed: + cat.add("sync-state", Severity.warn, text, "Check network access and remote permissions") + cat.add("remote", Severity.warn, "remote — not reachable", "Check network access and remote permissions") + else: + if not state.has_upstream: + cat.add("sync-state", Severity.warn, text, "Run: git -C push -u ") + elif state.diverged: + cat.add("sync-state", Severity.warn, text, "Resolve manually; agent-memory never merges memory") + elif state.behind: + cat.add("sync-state", Severity.warn, text, "Run: agent-memory pull") + elif state.ahead: + cat.add("sync-state", Severity.warn, text, "Run: agent-memory push") + else: + cat.add("sync-state", Severity.ok, text) + cat.add("remote", Severity.ok, "remote — reachable") + + if not _has_commits(home, runner): + cat.add("last-commit", Severity.warn, "last commit — none", "Run: agent-memory push") + else: + age_days = _last_commit_age_days(home, runner, now=now) + if age_days is None: + cat.add("last-commit", Severity.warn, "last commit — timestamp unavailable") + elif age_days > STALE_MEMORY_DAYS: + cat.add("last-commit", Severity.warn, f"last commit — stale ({age_days} day(s) old)", "Run: agent-memory push") + else: + cat.add("last-commit", Severity.ok, f"last commit — fresh ({age_days} day(s) old)") + + if tracked is not None: + agents_tracked = [ + path + for path in tracked + if path.startswith("agents/") or (path.startswith(f"{layout.PROJECTS_DIR}/") and "/agents/" in path) + ] + if agents_tracked: + cat.add( + "agents-tracked", + Severity.warn, + f"{len(agents_tracked)} agents/ file(s) tracked: {_summarize(agents_tracked)}", + "Remove per-instance agent state from the memory repo", + ) + else: + cat.add("agents-tracked", Severity.ok, "agents/ tracked files — none") + + overlays, failed = _overlay_gitignores(home) + escaping: List[str] = [] + for overlay in overlays: + rel = _relative_to_home(home, overlay) + try: + patterns = _escaping_overlay_patterns(overlay) + except (OSError, UnicodeDecodeError) as exc: + failed.append(f"{rel}: {_failure_text(exc)}") + continue + escaping.extend(f"{rel}: {pattern}" for pattern in patterns) + if failed: + # An overlay the doctor could not find or read may still escape memory/**; + # the row says the check is incomplete rather than counting it safe. + cat.add( + "memory-overlays", + Severity.warn, + f"{len(failed)} memory .gitignore overlay location(s) could not be inspected " + f"(overlay check incomplete): {_summarize(failed)}", + "Restore read access under projects/ (or re-encode the file as UTF-8) and re-run", + ) + elif escaping: + cat.add( + "memory-overlays", + Severity.warn, + f"memory .gitignore overlays can escape memory/**: {_summarize(escaping)}", + "Remove overlay unignore patterns containing '..'", + ) + else: + cat.add("memory-overlays", Severity.ok, f"memory .gitignore overlays — {len(overlays)} safe") + + return cat + + +def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]: + """The worktree root when ``home`` sits inside a repository that is not its own; ``None`` when it is the root.""" + root = sync.worktree_root(home, runner) + if root is None or root.resolve() == home.resolve(): + return None + return root + + +def sync_state_text(state: GitState) -> str: + """One phrase for where the repository stands; ``status`` and ``doctor`` share it.""" + if not state.has_remote: + return "local-only; no remote configured" + if state.fetch_failed: + return f"remote fetch failed: {state.fetch_output}" + if not state.has_upstream: + return "remote exists but no upstream branch is configured" + if state.diverged: + return f"DIVERGED from upstream ({state.ahead} ahead, {state.behind} behind)" + if state.behind: + return f"BEHIND upstream by {state.behind} commit(s)" + if state.ahead: + return f"ahead by {state.ahead} unpushed commit(s)" + return "synced with upstream" + + +# --- git readout helpers the doctor alone needs ----------------------------- + + +class _GitFailed(Exception): + """A git readout the doctor needs did not run; the row says so.""" + + +def _git(home: Path, args: Sequence[str], runner: Optional[GitRunner]) -> GitResult: + return (runner or run_git)(args, cwd=home) + + +def _ls_files(home: Path, runner: Optional[GitRunner], *options: str) -> List[str]: + result = _git(home, ["ls-files", *options], runner) + if not result.ok: + raise _GitFailed(f"{' '.join(['git', 'ls-files', *options])} failed: {result.output}") + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _has_commits(home: Path, runner: Optional[GitRunner]) -> bool: + return _git(home, ["rev-parse", "--verify", "HEAD"], runner).ok + + +def _last_commit_age_days(home: Path, runner: Optional[GitRunner], *, now: Optional[dt.datetime]) -> Optional[int]: + result = _git(home, ["log", "-1", "--format=%ct"], runner) + if not result.ok: + return None + try: + timestamp = int(result.stdout.strip()) + except ValueError: + return None + current = now or dt.datetime.now(dt.timezone.utc) + committed = dt.datetime.fromtimestamp(timestamp, tz=dt.timezone.utc) + return max(0, int((current - committed).total_seconds() // 86400)) + + +def _normalize_gitignore(text: str) -> str: + return text.replace("\r\n", "\n") + + +# --- filesystem probes ------------------------------------------------------ +# +# pathlib's exists/is_dir/is_file/glob answer False (or skip the subtree) when +# the probe is denied, which would let an unreadable path read as absent, and +# absent as fine. These probes return the answer when there is one and raise +# when there is not; the caller's row says the check is incomplete. + + +def _inspect(path: Path) -> Optional[os.stat_result]: + """``stat`` following symlinks: the result, ``None`` when the path is absent, ``OSError`` when it was denied.""" + try: + return os.stat(path) + except (FileNotFoundError, NotADirectoryError): + return None + + +def _is_dir(path: Path) -> bool: + result = _inspect(path) + return result is not None and stat.S_ISDIR(result.st_mode) + + +def _is_file(path: Path) -> bool: + result = _inspect(path) + return result is not None and stat.S_ISREG(result.st_mode) + + +def _failure_text(exc: Exception) -> str: + if isinstance(exc, UnicodeDecodeError): + return "not valid UTF-8" + return getattr(exc, "strerror", None) or exc.__class__.__name__ + + +def _not_inspected(exc: OSError) -> str: + return f"could not be inspected (setup check incomplete): {_failure_text(exc)}" + + +def _read_hint(exc: Exception) -> str: + return "Re-encode the file as UTF-8" if isinstance(exc, UnicodeDecodeError) else "" + + +def _relative_to_home(home: Path, path: Path) -> str: + try: + return path.relative_to(home).as_posix() + except ValueError: + return str(path) + + +def _overlay_gitignores(home: Path) -> Tuple[List[Path], List[str]]: + """The project overlays ``projects/

/memory/.gitignore`` that exist, and the locations + the discovery was denied, as ``: ``. + + A bounded walk over the tier pattern rather than ``Path.glob``: glob swallows a traversal + it is denied, so an unreadable project or memory directory would read as "no overlay". + Here a directory that is absent (or not a directory) is the only thing that means no + overlay; every other failure goes back to the caller's row. + """ + found: List[Path] = [] + failed: List[str] = [] + candidates = [home] + for part in layout.PROJECT.parts: + expanded: List[Path] = [] + for base in candidates: + if part != layout.WILDCARD: + expanded.append(base / part) + continue + try: + with os.scandir(base) as entries: + names = sorted(entry.name for entry in entries) + except (FileNotFoundError, NotADirectoryError): + continue + except OSError as exc: + failed.append(f"{_relative_to_home(home, base)}/: {_failure_text(exc)}") + continue + expanded.extend(base / name for name in names) + candidates = expanded + for directory in candidates: + overlay = directory / layout.GITIGNORE_FILE + try: + # lstat, so a dangling overlay symlink is found and then fails to read, never skipped. + os.lstat(overlay) + except (FileNotFoundError, NotADirectoryError): + continue + except OSError as exc: + failed.append(f"{_relative_to_home(home, overlay)}: {_failure_text(exc)}") + continue + found.append(overlay) + return sorted(found), failed + + +def _escaping_overlay_patterns(path: Path) -> List[str]: + """Unignore patterns in a project overlay whose path climbs out of ``memory/``.""" + bad: List[str] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or not line.startswith("!"): + continue + pattern = line[1:].strip() + parts = [part for part in pattern.replace("\\", "/").split("/") if part] + if ".." in parts: + bad.append(raw) + return bad + + +def _summarize(paths: Sequence[str], *, limit: int = 3) -> str: + if not paths: + return "" + shown = ", ".join(paths[:limit]) + if len(paths) > limit: + shown += f", +{len(paths) - limit} more" + return shown diff --git a/src/agent_memory/git_runner.py b/src/agent_memory/git_runner.py new file mode 100644 index 0000000..478a91b --- /dev/null +++ b/src/agent_memory/git_runner.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Run git for the sync engine. + +Every git invocation goes through one :class:`GitRunner`, so tests can record +calls or script answers without a repository, and the network verbs can carry +a timeout the default runner enforces. The runner never interprets git's +output; that is the engine's job. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Protocol, Sequence + +#: Exit codes the default runner synthesizes when git itself did not run. +EXIT_TIMEOUT = 124 +EXIT_NOT_FOUND = 127 + + +@dataclass(frozen=True) +class GitResult: + """What one git invocation returned.""" + + returncode: int + stdout: str = "" + stderr: str = "" + + @property + def ok(self) -> bool: + return self.returncode == 0 + + @property + def timed_out(self) -> bool: + return self.returncode == EXIT_TIMEOUT + + @property + def output(self) -> str: + """stdout and stderr together, stripped: the text for a message.""" + return "\n".join(part for part in (self.stdout, self.stderr) if part).strip() + + +class GitRunner(Protocol): + """Run ``git`` with ``args`` in ``cwd``; ``timeout`` is seconds or ``None``.""" + + def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: ... + + +def run_git(args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: + """The default runner: a subprocess, output captured, timeouts enforced.""" + command = ["git", *args] + try: + completed = subprocess.run( + command, + cwd=str(cwd), + capture_output=True, + encoding="utf-8", + errors="surrogateescape", + check=False, + timeout=timeout, + ) + except FileNotFoundError: + return GitResult(EXIT_NOT_FOUND, "", "git: command not found") + except subprocess.TimeoutExpired: + return GitResult(EXIT_TIMEOUT, "", f"git {' '.join(args)}: timed out after {timeout:g}s") + return GitResult(completed.returncode, completed.stdout, completed.stderr) diff --git a/src/agent_memory/home.py b/src/agent_memory/home.py new file mode 100644 index 0000000..64ded89 --- /dev/null +++ b/src/agent_memory/home.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Find the memory home. + +Resolution order; the first hit wins: + +1. an explicit path (the ``--home`` flag); +2. ``$AGENT_MEMORY_HOME``; +3. ``$OACP_HOME``, recognized so existing homes keep working, never required; +4. the nearest ``.agent-memory.json`` binding, walking up from the working + directory: ``{"schema_version": 1, "home": "...", "project": "..."}``; +5. a workspace marker, walking up from the working directory: a symlink, or a + file named ``workspace.json``, whose real path has the shape + ``/projects//workspace.json``; the marker names the project too; +6. ``~/agent-memory``. + +Any directory entry with the binding's name is the binding, and one that +is not a readable, well-formed v1 binding -- a dangling symlink, a directory, +unreadable or malformed JSON, an unknown ``schema_version``, no home -- is an +error, never a fall-through: silently picking a different store is worse +than stopping. So is an ancestor directory the process cannot inspect: it +might hold a binding, and only a directory known to hold none keeps the +walk going. The resolver only reads paths; it never asks whether any other +tool is installed. + +The project is resolved separately (:func:`find_project`): when a flag or +an environment variable chose the home, the nearest binding or marker still +names the project, provided it binds the repository to that same home. A +binding for a different home lends nothing; the mismatch is reported. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Mapping, Optional, Tuple + +from .layout import PROJECTS_DIR + +ENV_HOME = "AGENT_MEMORY_HOME" +ENV_COMPAT_HOME = "OACP_HOME" +BINDING_FILE = ".agent-memory.json" +BINDING_SCHEMA_VERSION = 1 +WORKSPACE_FILE = "workspace.json" +DEFAULT_HOME = "~/agent-memory" + +SOURCE_FLAG = "flag" +SOURCE_DEFAULT = "default" + + +class HomeError(Exception): + """The home could not be resolved safely.""" + + +@dataclass(frozen=True) +class HomeResolution: + """Where the home is and which rule chose it.""" + + path: Path + #: ``flag``, ``env:``, ``binding:``, ``marker:`` or ``default``. + source: str + #: The project a binding or a marker named, when one of them chose the home. + project: Optional[str] = None + + +def resolve_home( + explicit: Optional[str] = None, + *, + env: Optional[Mapping[str, str]] = None, + cwd: Optional[Path] = None, +) -> HomeResolution: + """Apply the resolution order and return the first hit. + + ``env`` and ``cwd`` default to the process environment and working + directory; tests pass their own to stay hermetic. + """ + environ: Mapping[str, str] = os.environ if env is None else env + if explicit is not None: + return HomeResolution(_expand(explicit), SOURCE_FLAG) + for name in (ENV_HOME, ENV_COMPAT_HOME): + value = environ.get(name) + if value: + return HomeResolution(_expand(value), f"env:{name}") + start = (Path.cwd() if cwd is None else Path(cwd)).expanduser().absolute() + for finder in (find_binding, find_workspace_marker): + found = finder(start) + if found is not None: + return found + return HomeResolution(_expand(DEFAULT_HOME), SOURCE_DEFAULT) + + +@dataclass(frozen=True) +class ProjectResolution: + """Which project the repository at hand belongs to, and how that was decided.""" + + project: Optional[str] + #: ``binding:`` or ``marker:``; ``None`` when no project was found. + source: Optional[str] + #: Why a binding or marker that was found did not name the project, when one was found. + note: Optional[str] = None + + +def find_project(home: Path, start: Path) -> ProjectResolution: + """The project the nearest binding or marker at or above ``start`` names for ``home``. + + The same walk :func:`resolve_home` makes, with the same fail-closed binding + handling; the first binding or marker found decides. It names the project + only when it binds the repository to ``home`` itself: a binding for another + home says nothing about this one, and borrowing its project would list the + wrong files. + """ + start = Path(start).expanduser().absolute() + for finder in (find_binding, find_workspace_marker): + found = finder(start) + if found is None: + continue + if not _same_path(found.path, home): + return ProjectResolution( + None, None, f"{found.source} binds this repository to {found.path}, not to {home}; no project taken from it" + ) + if found.project is None: + return ProjectResolution(None, None, f"{found.source} names no project") + return ProjectResolution(found.project, found.source) + return ProjectResolution(None, None) + + +def _same_path(first: Path, second: Path) -> bool: + return os.path.realpath(Path(first).expanduser()) == os.path.realpath(Path(second).expanduser()) + + +def find_binding(start: Path) -> Optional[HomeResolution]: + """Load the nearest binding entry at or above ``start``; ``None`` when there is none. + + Any directory entry with the binding's name counts, a dangling symlink or + a directory included: an entry that turns out unusable is an error from + :func:`load_binding`, never a reason to keep walking up. + """ + for directory in _ancestors(start): + candidate = directory / BINDING_FILE + try: + os.lstat(candidate) + except FileNotFoundError: + continue + except OSError as exc: + # An ancestor the process may not inspect could hold a binding; walking + # past it would silently pick another store. Absence and inability are + # different answers, and only the first keeps the walk going. + raise HomeError(f"{candidate}: cannot inspect the binding slot: {exc.strerror or exc}") from exc + return load_binding(candidate) + return None + + +def load_binding(path: Path) -> HomeResolution: + """Parse one binding file; anything but a well-formed v1 binding raises :class:`HomeError`.""" + if not path.is_file(): + raise HomeError(f"{path}: binding {_describe_non_file(path)}") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise HomeError(f"{path}: cannot read binding: {exc}") from exc + if not isinstance(data, dict): + raise HomeError(f"{path}: binding must be a JSON object") + version = data.get("schema_version") + if isinstance(version, bool) or not isinstance(version, int) or version != BINDING_SCHEMA_VERSION: + raise HomeError( + f"{path}: unsupported binding schema_version {version!r} (this tool reads {BINDING_SCHEMA_VERSION})" + ) + home = data.get("home") + if not isinstance(home, str) or not home: + raise HomeError(f"{path}: binding must name a non-empty 'home'") + project = data.get("project") + if project is not None and (not isinstance(project, str) or not project): + raise HomeError(f"{path}: binding 'project' must be a non-empty string when present") + home_path = _expand(home) + if not home_path.is_absolute(): + home_path = path.parent / home_path + return HomeResolution(home_path, f"binding:{path}", project=project) + + +def find_workspace_marker(start: Path) -> Optional[HomeResolution]: + """Find the nearest workspace marker at or above ``start``; ``None`` when there is none. + + A marker is any symlink, or a file named ``workspace.json``, whose real + path has the shape ``/projects//workspace.json``. The shape + is the guard: an editor's ``workspace.json`` in a repo root never sits + two levels below a ``projects`` directory. The symlink's own name is not + load-bearing, so a repo can call it whatever it likes. + """ + for directory in _ancestors(start): + for candidate in _marker_candidates(directory): + found = _home_from_workspace_file(candidate) + if found is not None: + home, project = found + return HomeResolution(home, f"marker:{candidate}", project=project) + return None + + +def _marker_candidates(directory: Path) -> Iterator[Path]: + plain = directory / WORKSPACE_FILE + if plain.exists(): + yield plain + try: + entries = sorted(directory.iterdir()) + except OSError: + return + for entry in entries: + if entry.name != WORKSPACE_FILE and entry.is_symlink(): + yield entry + + +def _home_from_workspace_file(path: Path) -> Optional[Tuple[Path, str]]: + """``(home, project)`` when ``path`` resolves to ``/projects//workspace.json``.""" + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError): + return None + if resolved.name != WORKSPACE_FILE or not resolved.is_file(): + return None + projects = resolved.parent.parent + if projects.name != PROJECTS_DIR: + return None + return projects.parent, resolved.parent.name + + +def _ancestors(start: Path) -> Iterator[Path]: + yield start + yield from start.parents + + +def _describe_non_file(path: Path) -> str: + if path.is_dir(): + return "is a directory, not a file" + if path.is_symlink(): + return "is a symlink whose target is missing" + if not os.path.lexists(path): + return "does not exist" + return "is not a regular file" + + +def _expand(value: str) -> Path: + try: + return Path(value).expanduser() + except RuntimeError as exc: # ``~user`` for a user the system cannot look up + raise HomeError(f"cannot expand {value!r}: {exc}") from exc diff --git a/src/agent_memory/layout.py b/src/agent_memory/layout.py new file mode 100644 index 0000000..ebf16f8 --- /dev/null +++ b/src/agent_memory/layout.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""The memory-home layout, declared once. + +Every other encoding of the layout is derived from the ``TIERS`` table +below: the sync allowlist written to a home's ``.gitignore``, the directories +a sync may stage, the per-path allow check, and the files and subdirectories +a fresh tier starts with. Change the table and every derivation follows; +nothing else in the package spells these names. + +A home has two tiers:: + + / + .gitignore the sync allowlist (gitignore_text) + .oacp-memory-repo present when the home syncs through git + org-memory/ cross-project memory + projects//memory/ per-project memory +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterator, List, Optional, Sequence, Tuple + +#: Marks a home whose memory tiers sync through git. The name is a compatibility +#: contract with every existing home; keep it verbatim. +MARKER_FILE = ".oacp-memory-repo" +GITIGNORE_FILE = ".gitignore" +PROJECTS_DIR = "projects" +#: Local setup receipts, one per runtime and repository. Not a tier: the allowlist +#: never selects it, so it stays on the machine that wrote it. +SETUP_DIR = "setup" +WILDCARD = "*" + +#: Top-level directories that must never sync, listed after every allow rule so +#: the deny wins even if the allowlist is widened later. +NEVER_SYNCED_DIRS: Tuple[str, ...] = ("keys",) +_NEVER_SYNCED_COMMENT = "# never sync private key material — explicit deny, wins over any future allowlist widening" + + +@dataclass(frozen=True) +class Tier: + """One memory tier: where it lives under the home and what a fresh one holds.""" + + name: str + #: Path pattern relative to the home; ``*`` stands for one project name. + pattern: str + #: Files a fresh tier directory starts with; their content is the scaffolding verbs' business. + files: Tuple[str, ...] + #: Subdirectories a fresh tier directory starts with. + dirs: Tuple[str, ...] + #: Subdirectories inside the tier that never sync. + unsynced: Tuple[str, ...] = () + + @property + def parts(self) -> Tuple[str, ...]: + return tuple(self.pattern.split("/")) + + +ORG = Tier( + name="org", + pattern="org-memory", + files=("recent.md", "decisions.md", "rules.md"), + dirs=("events", "debriefs"), +) +PROJECT = Tier( + name="project", + pattern=f"{PROJECTS_DIR}/{WILDCARD}/memory", + files=("project_facts.md", "decision_log.md", "open_threads.md", "known_debt.md"), + dirs=("archive",), + unsynced=(".cache",), +) + +#: The whole layout. The order is the order of the allowlist lines. +TIERS: Tuple[Tier, ...] = (ORG, PROJECT) + + +def gitignore_text() -> str: + """The canonical sync allowlist for a home's ``.gitignore``, byte for byte.""" + lines = [WILDCARD, f"!{WILDCARD}/", f"!{GITIGNORE_FILE}", f"!{MARKER_FILE}"] + lines.extend(f"!{tier.pattern}/**" for tier in TIERS) + lines.extend(f"{tier.pattern}/{sub}/" for tier in TIERS for sub in tier.unsynced) + lines.append(_NEVER_SYNCED_COMMENT) + lines.extend(f"{name}/" for name in NEVER_SYNCED_DIRS) + return "\n".join(lines) + "\n" + + +def org_memory_dir(home: Path) -> Path: + return home.joinpath(*ORG.parts) + + +def project_memory_dir(home: Path, project: str) -> Path: + validate_project_name(project) + return home.joinpath(*(project if part == WILDCARD else part for part in PROJECT.parts)) + + +def validate_project_name(project: str) -> None: + if not project or project.startswith(".") or "/" in project or "\\" in project: + raise ValueError("project name must be non-empty, contain no path separators and not start with '.'") + + +def allowed_memory_dirs(home: Path) -> List[Path]: + """Existing tier directories under ``home``, in allowlist order; projects sorted by name.""" + return [path for tier in TIERS for path in _expand(home, tier.parts) if path.exists()] + + +def _expand(base: Path, parts: Sequence[str]) -> Iterator[Path]: + if not parts: + yield base + return + head, rest = parts[0], parts[1:] + if head != WILDCARD: + yield from _expand(base / head, rest) + return + try: + children = sorted(base.iterdir()) + except OSError: + return + for child in children: + yield from _expand(child, rest) + + +def is_allowed_memory_path(path: str) -> bool: + """Whether a home-relative POSIX path is inside the sync allowlist. + + A component named in ``NEVER_SYNCED_DIRS`` denies the path at any depth, + whatever the home's ``.gitignore`` says: the predicate, not the ignore + file, is what the sync engine trusts. + """ + if path in (GITIGNORE_FILE, MARKER_FILE): + return True + parts = path.split("/") + if any(part in NEVER_SYNCED_DIRS for part in parts): + return False + for tier in TIERS: + pattern = tier.parts + if len(parts) <= len(pattern): + continue + if all(want == WILDCARD or want == have for want, have in zip(pattern, parts)): + return parts[len(pattern)] not in tier.unsynced + return False + + +def scaffold_home(home: Path, project: Optional[str] = None) -> List[Path]: + """Lay out ``home``: its directories, the canonical ``.gitignore`` and the org + tier, plus one project tier when ``project`` is given. + + Only missing paths are created and nothing that exists is touched, so a + rerun on a complete home returns an empty list. The ``.gitignore`` slot + has a tier file's guarantee: whatever occupies it, a dangling link + included, is kept and never followed (:func:`write_if_absent`). Tier + *files* are not written here; their content ships with the scaffolding + verbs. + + Returns the paths created, in creation order. + """ + created: List[Path] = [] + + def mkdir(path: Path) -> None: + if not path.is_dir(): + path.mkdir(parents=True) + created.append(path) + + mkdir(home) + gitignore = home / GITIGNORE_FILE + if write_if_absent(gitignore, gitignore_text().encode("utf-8")): + created.append(gitignore) + mkdir(home / PROJECTS_DIR) + _scaffold_tier(ORG, org_memory_dir(home), mkdir) + if project is not None: + _scaffold_tier(PROJECT, project_memory_dir(home, project), mkdir) + return created + + +def write_if_absent(path: Path, data: bytes) -> bool: + """Create ``path`` holding ``data`` when no directory entry is there; True when it was created. + + Whatever occupies the slot is kept and never followed: a regular file, a + directory, or a symlink, dangling included. The existence probe is only + advisory; the open is exclusive, so an entry that appears between the two + is kept as well. Any other failure propagates as ``OSError``. + """ + if os.path.lexists(path): + return False + try: + with open(path, "xb") as handle: + handle.write(data) + except FileExistsError: + return False + return True + + +def _scaffold_tier(tier: Tier, root: Path, mkdir: Callable[[Path], None]) -> None: + mkdir(root) + for sub in tier.dirs: + mkdir(root / sub) diff --git a/src/agent_memory/org.py b/src/agent_memory/org.py new file mode 100644 index 0000000..24ca72c --- /dev/null +++ b/src/agent_memory/org.py @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Scaffold a memory home from the bundled templates: ``agent-memory init`` and ``org init``. + +``init`` lays the home out (its directories and the canonical ``.gitignore``, +through :func:`layout.scaffold_home`), writes the org tier's files from the +templates shipped inside this package, keeps ``events/`` and ``debriefs/`` +alive with a ``.gitkeep``, and, given a project, writes that project's tier +the same way. Given a repository, it records a binding there last, so a +later session started inside that repository finds this home and project. +``org init`` is the org tier alone, for a home that already exists. + +Three rules, in the order they run: + +1. Every template is read through the public ``importlib.resources`` API + before anything is written. A template missing from the installed package + is an error and the home is untouched; there is no silent fallback. +2. Nothing that exists is overwritten, so a rerun changes no byte and reports + what it kept. A binding that would collide with one already recorded is + refused before the first write. +3. No git, no network, no credentials: the verbs touch the filesystem only. + Making the home a sync repository is ``enable``, a separate step. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from . import layout +from .home import BINDING_FILE, BINDING_SCHEMA_VERSION, HomeError, load_binding + +TEMPLATES_DIR = "templates" +#: Template directory per tier; the file names are the tier's own file list. +TEMPLATE_DIRS = {layout.ORG.name: "org-memory", layout.PROJECT.name: "project-memory"} +#: Keeps an otherwise empty org-tier directory present in a git-synced home. +KEEP_FILE = ".gitkeep" + + +class ScaffoldError(Exception): + """A precondition failed, or a template is missing; nothing was changed.""" + + +@dataclass(frozen=True) +class Report: + """What the verb created, what it found already in place, and the binding it recorded.""" + + home: Path + created: Tuple[str, ...] + kept: Tuple[str, ...] + project: Optional[str] = None + binding: Optional[Path] = None + #: ``created``, ``unchanged`` or ``""`` when no repository was given. + binding_action: str = "" + + @property + def changed(self) -> bool: + return bool(self.created) or self.binding_action == "created" + + def lines(self) -> List[str]: + head = "Initialized memory home" if self.created else "Memory home already complete" + lines = [f"{head}: {self.home}"] + lines.extend(f" + {path}" for path in self.created) + lines.extend(f" (exists) {path}" for path in self.kept) + if self.project: + lines.append(f"project: {self.project}") + if self.binding is not None: + verb = "recorded" if self.binding_action == "created" else "already recorded" + lines.append(f"binding {verb}: {self.binding} -> {self.home}") + if self.binding_action == "created": + lines.append(f" keep {BINDING_FILE} out of the repository's history; it holds a machine-local path") + return lines + + +# --- the verbs -------------------------------------------------------------- + + +def init(home: Path, *, project: Optional[str] = None, repo: Optional[Path] = None) -> Report: + """Create or complete ``home``; with ``project`` its project tier too; with ``repo`` a binding in that repository.""" + home = _absolute(home) + templates = load_templates() + if repo is not None: + repo = _absolute(repo) + if project is None: + project = repo.name + try: + layout.validate_project_name(project) + except ValueError as exc: + raise ScaffoldError(f"cannot derive a project name from {repo}: {exc}; pass --project") from exc + if project is not None: + try: + layout.validate_project_name(project) + except ValueError as exc: + raise ScaffoldError(f"project {project!r}: {exc}") from exc + binding: Optional[Tuple[Path, str]] = None + if repo is not None: + binding = _plan_binding(repo, home, project) + created, kept = _scaffold(home, project, templates) + action = "" + if binding is not None: + path, action = binding + if action == "created": + _write_binding(path, home, project) + return Report( + home, + tuple(created), + tuple(kept), + project=project, + binding=binding[0] if binding is not None else None, + binding_action=action, + ) + + +def org_init(home: Path) -> Report: + """The org tier alone, for a home that already exists.""" + home = _absolute(home) + if not home.is_dir(): + raise ScaffoldError(f"{home} is not a directory; `agent-memory init` creates a home") + return init(home) + + +# --- templates -------------------------------------------------------------- + + +def _templates_root(): + """The bundled ``templates/`` directory as a ``Traversable``; tests point this elsewhere.""" + return resources.files(__package__) / TEMPLATES_DIR + + +def template_bytes(tier: layout.Tier, name: str) -> bytes: + """The bytes of one bundled template, or :class:`ScaffoldError` when the package does not carry it.""" + relative = f"{TEMPLATES_DIR}/{TEMPLATE_DIRS[tier.name]}/{name}" + try: + with resources.as_file(_templates_root() / TEMPLATE_DIRS[tier.name] / name) as path: + return path.read_bytes() + except FileNotFoundError as exc: + raise ScaffoldError(f"template {relative} is missing from the installed package") from exc + except OSError as exc: + raise ScaffoldError(f"template {relative} cannot be read: {exc.strerror or exc}") from exc + + +def load_templates() -> Dict[Tuple[str, str], bytes]: + """Every tier file's template, read up front so a missing one fails before the first write.""" + return {(tier.name, name): template_bytes(tier, name) for tier in layout.TIERS for name in tier.files} + + +# --- the writes ------------------------------------------------------------- + + +def _scaffold(home: Path, project: Optional[str], templates: Dict[Tuple[str, str], bytes]) -> Tuple[List[str], List[str]]: + created: List[str] = [] + kept: List[str] = [] + + def rel(path: Path) -> str: + text = path.relative_to(home).as_posix() + return f"{text}/" if path.is_dir() else text + + try: + created.extend(rel(path) for path in layout.scaffold_home(home, project) if path != home) + except OSError as exc: + raise ScaffoldError(f"cannot lay out {home}: {exc.strerror or exc}: {exc.filename}") from exc + + def place(path: Path, data: bytes) -> None: + try: + fresh = layout.write_if_absent(path, data) + except OSError as exc: + raise ScaffoldError(f"cannot write {path}: {exc.strerror or exc}") from exc + (created if fresh else kept).append(rel(path)) + + org = layout.org_memory_dir(home) + for name in layout.ORG.files: + place(org / name, templates[(layout.ORG.name, name)]) + for sub in layout.ORG.dirs: + place(org / sub / KEEP_FILE, b"") + if project is not None: + memory = layout.project_memory_dir(home, project) + for name in layout.PROJECT.files: + place(memory / name, templates[(layout.PROJECT.name, name)]) + return created, kept + + +def _plan_binding(repo: Path, home: Path, project: str) -> Tuple[Path, str]: + """Where the binding goes and whether it needs writing; a collision is refused here, before any write.""" + if not repo.is_dir(): + raise ScaffoldError(f"{repo} is not a directory") + path = repo / BINDING_FILE + if not os.path.lexists(path): + return path, "created" + try: + existing = load_binding(path) + except HomeError as exc: + raise ScaffoldError(f"collision: {exc}; not overwriting") from exc + if existing.path.resolve() == home.resolve() and existing.project == project: + return path, "unchanged" + raise ScaffoldError( + f"collision: {path} already binds this repository to home {existing.path} " + f"(project {existing.project!r}); not overwriting" + ) + + +def _write_binding(path: Path, home: Path, project: str) -> None: + data = {"schema_version": BINDING_SCHEMA_VERSION, "project": project, "home": str(home)} + try: + with open(path, "x", encoding="utf-8") as handle: + handle.write(json.dumps(data, indent=2) + "\n") + except OSError as exc: + raise ScaffoldError(f"cannot write {path}: {exc.strerror or exc}") from exc + + +def _absolute(value: Path) -> Path: + try: + return Path(value).expanduser().absolute() + except RuntimeError as exc: + raise ScaffoldError(f"cannot expand {value}: {exc}") from exc diff --git a/src/agent_memory/publication.py b/src/agent_memory/publication.py new file mode 100644 index 0000000..ee0fca2 --- /dev/null +++ b/src/agent_memory/publication.py @@ -0,0 +1,433 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Failure-atomic publication of one immutable record: stage, verify, link, read back. + +The one implementation of the writer commit contract in the package; the +debrief writer uses it today and the event writer reuses it next. The +canonical path only ever holds a complete, verified record, never partial +bytes (one qualification, on platforms without a descriptor-bound link, is +stated below): the record is staged in a writer-owned private file, verified through +the descriptor that created it, then published with an atomic no-replace +primitive (``os.link``). A failure before publication leaves the canonical +namespace clean. + +Staging ownership is a single invariant: **this writer only ever publishes an +inode it created itself.** The staging nonce is unpredictable, the staging +file is created with ``O_CREAT|O_EXCL|O_NOFOLLOW``, its bytes are verified +through that same descriptor, and the published name is confirmed to resolve +to that same ``(st_dev, st_ino)``. A pre-existing path is never read, adopted, +or linked. Stale staging artifacts are swept *after* the record is published, +when no writer of it can still need one. + +What a record must satisfy internally is the caller's business: ``publish`` +takes a ``verify`` callable that raises :class:`WriterError` when the bytes it +is handed are not a consistent record, and runs it over the staged bytes and +over the read-back. + +Publication is bound to the verified descriptor where the platform can name +one: on Linux the link source is ``/proc/self/fd/``, and a staging name +swapped for a link to some other file meanwhile has orphaned the verified +inode, which the kernel then refuses to link (ENOENT): nothing foreign is ever +visible. Elsewhere (macOS, a Linux without ``/proc``) the link source is the +staging name and the contract is narrower: a swap in the window between +verification and the link makes the foreign file visible under the canonical +name from the link until the identity check that follows takes that name back +down, and a writer stopped in that interval leaves it there (the next writer +of the record reports it as a collision). What holds on such platforms is the +post-call state: the call returns with the verified record published, or +raises with the canonical path absent. That contract rests on the store +directory not being writable by other users, its default mode, so the swap +needs the owner's own uid; it is an accepted, documented platform limitation, +and the structural close is a private staging directory with the link bound +to that directory's descriptor. Either way the writer treats the swap as a +vanished stage, restages under a fresh nonce and retries; a swap that +persists exhausts the attempts and is reported, with the canonical path +absent. +""" + +from __future__ import annotations + +import errno +import os +import stat as stat_mod +import sys +from pathlib import Path +from typing import Callable, Optional, Tuple + +# The leading dot keeps staging files outside the canonical namespace; the +# prefix is scoped to one canonical record, so every file matching it belongs +# to a writer of that exact record. +STAGE_PREFIX = ".stage." + +# A staging file vanishes before publication when a concurrent writer of this +# record published and swept it (the sweep runs only after a record is +# published), or when its name was swapped under the writer (see the module +# docstring). A vanished stage is resolved against the landed record first: +# identical is idempotent, different is a collision, nothing landed is a +# restage. The vanish shows up at three points -- the fstat after the O_EXCL +# create reports 0 links, the link reports the stage missing, or the published +# name fails to resolve to the verified inode -- and all three raise +# _StageVanished. The bound covers the window where a sweep beat the publish +# into visibility, and turns a persistent swap into a reported failure. +PUBLISH_ATTEMPTS = 3 + +# Linux names an open descriptor's inode under /proc/self/fd, and linkat with +# AT_SYMLINK_FOLLOW publishes from that name: the link source is then the +# verified inode itself, not the staging name. Elsewhere the source is the +# name (see the module docstring). +LINK_VIA_FD = sys.platform.startswith("linux") and os.path.isdir("/proc/self/fd") + +Verifier = Callable[[bytes], None] + + +class WriterError(Exception): + """A validation or publication failure. Never leaves partial bytes. + + ``code`` is the process exit code the failure maps to: 1 for a usage or + validation error, 2 for a publication failure. + """ + + def __init__(self, message: str, code: int = 2) -> None: + super().__init__(message) + self.code = code + + +class _StageVanished(Exception): + """Internal: the staging file disappeared before it could be published.""" + + +def staging_path(target: Path) -> Path: + """Writer-unique private staging name for ``target``. + + The nonce is unpredictable, which is what makes the ownership invariant + hold: no other process can pre-create the path this writer is about to + claim, so the ``O_EXCL`` create below always produces a fresh inode that + this writer alone has ever written to. + """ + nonce = f"{os.getpid():x}{os.urandom(8).hex()}" + return target.with_name(f"{STAGE_PREFIX}{target.name}.{nonce}") + + +def _pread_all(fd: int, size: int) -> bytes: + chunks = [] + offset = 0 + while offset < size: + chunk = os.pread(fd, size - offset, offset) + if not chunk: + break + chunks.append(chunk) + offset += len(chunk) + return b"".join(chunks) + + +def _identity(path: Path) -> Tuple[int, int]: + """The (device, inode) pair naming one filesystem object, without following.""" + try: + st = os.lstat(path) + except OSError as exc: + raise WriterError(f"cannot inspect {path}: {exc}") from exc + return (st.st_dev, st.st_ino) + + +def _unlink_quietly(path: Path) -> None: + """Best-effort removal of a staging name. Never fatal, by design. + + Every call site is cleanup: an error path that is already raising the real + failure, the ``finally`` that releases the staging name after publication, + or the post-publication sweep. Re-raising from any of them would replace an + accurate outcome with an incidental one. A stage that survives the + ``finally`` leaves the published inode with a second name, and the caller + asserts ``st_nlink == 1`` immediately after, turning exactly that case into + a reported failure. + """ + try: + os.unlink(path) + except OSError: + # Deliberate: see the docstring. + pass + + +def read_publishable_target(target: Path) -> Optional[bytes]: + """Return the existing canonical bytes, or None when the path is free. + + Never follows symlinks: a symlink or any non-regular file at the canonical + path is a hard failure, not something to read through. The read is bound + to the inode that was inspected -- a file swapped in between the inspection + and the open is reported rather than silently accepted. + """ + try: + lst = os.lstat(target) + except FileNotFoundError: + return None + except OSError as exc: + raise WriterError(f"cannot inspect canonical path {target}: {exc}") from exc + + if stat_mod.S_ISLNK(lst.st_mode): + raise WriterError( + f"canonical path {target} is a symlink; the store holds regular files only and the writer must not follow links" + ) + if not stat_mod.S_ISREG(lst.st_mode): + raise WriterError(f"canonical path {target} exists and is not a regular file") + + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(target, flags) + except FileNotFoundError: + return None + except OSError as exc: + if getattr(exc, "errno", None) == errno.ELOOP: + raise WriterError(f"canonical path {target} became a symlink while it was being read") from exc + raise WriterError(f"cannot open canonical path {target}: {exc}") from exc + try: + st = os.fstat(fd) + if not stat_mod.S_ISREG(st.st_mode): + raise WriterError(f"canonical path {target} exists and is not a regular file") + if (st.st_dev, st.st_ino) != (lst.st_dev, lst.st_ino): + raise WriterError(f"canonical path {target} was replaced while it was being read") + return _pread_all(fd, st.st_size) + finally: + os.close(fd) + + +def _sweep_own_stages(target: Path, canonical: Optional[Tuple[int, int]] = None) -> None: + """Remove staging artifacts for ``target`` left by writers of this record. + + Called only once the canonical record is published and verified. Two kinds + of match are removable, and only those two: a single-link regular file + owned by this euid (a stale or partial stage), and a regular file owned by + this euid that shares ``canonical``, the record's own inode (an alias left + behind when the ``finally`` unlink failed, which keeps the immutable record + writable under a second name). Anything else -- a symlink, a directory, a + multi-link file that is not the record, another user's file -- is left in + place. It was never this writer's to delete. + """ + prefix = f"{STAGE_PREFIX}{target.name}." + euid = os.geteuid() + try: + entries = os.listdir(target.parent) + except OSError: + # The record is already published and verified; the sweep is tidying only. + return + for name in entries: + if not name.startswith(prefix): + continue + candidate = target.parent / name + try: + st = os.lstat(candidate) + except OSError: + continue + if not stat_mod.S_ISREG(st.st_mode) or st.st_uid != euid: + continue + is_record_alias = canonical is not None and (st.st_dev, st.st_ino) == canonical + if st.st_nlink != 1 and not is_record_alias: + continue + _unlink_quietly(candidate) + + +def _link_verified(stage: Path, fd: int, target: Path) -> None: + """Give the verified inode its canonical name, never replacing anything. + + Where the platform can name the descriptor, the link source is the + descriptor and the staging name is irrelevant; elsewhere it is the name. + Either way the call fails with FileExistsError when the canonical name is + taken, which is the collision guard the contract wants. + """ + if not LINK_VIA_FD: + os.link(stage, target) + return + dir_fd = os.open(target.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + # A destination dir_fd selects linkat(); follow_symlinks=True gives it + # AT_SYMLINK_FOLLOW, which is what resolves the /proc name to the inode. + os.link(f"/proc/self/fd/{fd}", target.name, dst_dir_fd=dir_fd, follow_symlinks=True) + finally: + os.close(dir_fd) + + +def _stage(target: Path, record: bytes, verify: Verifier) -> Tuple[Path, Tuple[int, int], int]: + """Create, write and verify a staging file this writer owns outright. + + Returns ``(path, (st_dev, st_ino), fd)``. The identity pair is what binds + verification to publication: the caller confirms the canonical name lands + on this exact inode, so the bytes that were checked here are provably the + bytes that became the record. The descriptor is returned open so the + publication link can be bound to it; the caller closes it. + """ + stage = staging_path(target) + # O_RDWR, not O_WRONLY: the staged bytes are read back through this same + # descriptor, which is what binds the verification to the published inode. + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(stage, flags, 0o644) + except OSError as exc: + raise WriterError(f"cannot create staging file {stage}: {exc}") from exc + + try: + written = os.write(fd, record) + if written != len(record): + raise WriterError(f"short write staging {stage}: {written} of {len(record)} bytes") + os.fsync(fd) + + st = os.fstat(fd) + if not stat_mod.S_ISREG(st.st_mode): + raise WriterError(f"staging file {stage} is not a regular file") + if st.st_nlink == 0: + # Unlinked between the O_EXCL create and this fstat: a concurrent + # writer of this record published and swept the directory. The + # caller resolves against the landed record. + raise _StageVanished() + if st.st_nlink != 1: + raise WriterError( + f"staging file {stage} has {st.st_nlink} links; it must be the only " + "name for its inode or publication would share it with another path" + ) + + # Verify through the descriptor that created the file, never by + # reopening the path: the bytes checked and the inode published are + # then provably the same object. + staged = _pread_all(fd, st.st_size) + if len(staged) != len(record) or staged != record: + raise WriterError(f"staged bytes at {stage} do not match the composed record") + try: + verify(staged) + except WriterError as exc: + raise WriterError(f"staged record at {stage} is inconsistent: {exc}") from exc + ident = (st.st_dev, st.st_ino) + except BaseException: + os.close(fd) + _unlink_quietly(stage) + raise + return stage, ident, fd + + +def _collision_error(target: Path) -> WriterError: + return WriterError( + f"canonical path {target} already holds a different record; " + "never replace a published record -- re-publish under a new " + "session identifier instead" + ) + + +def _attempt_publish(target: Path, record: bytes, verify: Verifier) -> str: + stage, ident, fd = _stage(target, record, verify) + try: + try: + # Atomic no-replace publication; a replacing rename would be + # forbidden here. + _link_verified(stage, fd, target) + except FileExistsError: + landed = read_publishable_target(target) + if landed == record: + return "idempotent" + raise _collision_error(target) + except FileNotFoundError: + # A concurrent writer published and swept this stage; the caller + # resolves against the landed record. + raise _StageVanished() + finally: + # The descriptor has done its work, and the staging name is always + # released: on success the canonical path is the surviving link, on + # failure the namespace is left clean. + os.close(fd) + _unlink_quietly(stage) + + # The canonical name must resolve to the inode that was verified above -- + # not to some other file that appeared at that name in the meantime. + try: + landed_st = os.lstat(target) + except OSError as exc: + raise WriterError(f"cannot inspect published record {target}: {exc}") from exc + if (landed_st.st_dev, landed_st.st_ino) != ident: + # The staging name was turned into a link to some other file between + # verification and the link call (possible only where the link source + # is the name, not the descriptor). The canonical name is the one this + # call created -- the link would have failed had it existed -- so + # taking it back down restores the namespace; the foreign file was + # visible under that name from the link until here, and a writer + # stopped in between leaves it (the name-fallback contract, module + # docstring). The stage is then a vanished one, and the caller restages. + _unlink_quietly(target) + if os.path.lexists(target): + raise WriterError( + f"published record {target} does not resolve to the staged inode: the staging entry was " + "replaced before publication, and the foreign name the link created could not be removed" + ) + raise _StageVanished() + # Link count and read-back are proved in _finalize, which every successful + # return -- published and idempotent alike -- passes through. + return "published" + + +def _finalize(target: Path, record: bytes, verify: Verifier) -> None: + """Sweep staging artifacts, then prove the landed record stands alone. + + Every successful return from :func:`publish` passes through here, published + and idempotent alike. A previous run can have landed the record and then + failed to release its staging name, which leaves a second, writable name + for the canonical inode; finding the bytes already correct says nothing + about that. If the alias cannot be removed, this raises: a retained + publication failure is the honest outcome. + """ + canonical = _identity(target) + _sweep_own_stages(target, canonical) + + st = os.lstat(target) + if (st.st_dev, st.st_ino) != canonical: + raise WriterError(f"published record {target} was replaced during cleanup") + if st.st_nlink != 1: + raise WriterError( + f"published record {target} still has {st.st_nlink} links; the staging " + "name could not be released and the record is reachable -- and " + "writable -- under another path" + ) + + landed = read_publishable_target(target) + if landed is None: + raise WriterError(f"published record {target} disappeared before read-back") + try: + verify(landed) + except WriterError as exc: + raise WriterError(f"read-back mismatch at {target}: {exc}") from exc + if landed != record: + raise WriterError(f"read-back mismatch at {target}: bytes differ from the record") + + +def publish(target: Path, record: bytes, *, verify: Verifier) -> str: + """Publish ``record`` at ``target``. Returns 'published' or 'idempotent'. + + Failure-atomic: on any error before publication the canonical path is left + absent and the staging file is removed. ``verify`` is run over the staged + bytes and over the read-back, and raises :class:`WriterError` when the + bytes are not a consistent record. + """ + target.parent.mkdir(parents=True, exist_ok=True) + + existing = read_publishable_target(target) + if existing is not None: + if existing != record: + raise _collision_error(target) + # Idempotent retry after a success: nothing to write, but the previous + # run's invariants still have to hold before this one calls it success. + _finalize(target, record, verify) + return "idempotent" + + for attempt in range(PUBLISH_ATTEMPTS): + try: + status = _attempt_publish(target, record, verify) + except _StageVanished: + # The sweep that removed the stage ran after a record was published: + # ours byte-for-byte when the writers were identical. + landed = read_publishable_target(target) + if landed is not None: + if landed != record: + raise _collision_error(target) from None + _finalize(target, record, verify) + return "idempotent" + if attempt == PUBLISH_ATTEMPTS - 1: + raise WriterError( + f"staging file for {target} was removed before publication on {PUBLISH_ATTEMPTS} consecutive attempts" + ) from None + continue + _finalize(target, record, verify) + return status + raise AssertionError("unreachable") # pragma: no cover diff --git a/src/agent_memory/setup/__init__.py b/src/agent_memory/setup/__init__.py new file mode 100644 index 0000000..d258e50 --- /dev/null +++ b/src/agent_memory/setup/__init__.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory setup ``: one planner, one applier, a spec per runtime.""" + +from __future__ import annotations + +from typing import Dict + +from . import claude, codex +from .common import ( + Plan, + Result, + RuntimeSpec, + SetupError, + Step, + apply_plan, + detect_repo, + known_template_digests, + known_workflow_digests, + lines, + plan_setup, + run_setup, + script_text, + template_digest, + to_json, + workflow_digest, + workflow_text, +) + +SPECS: Dict[str, RuntimeSpec] = {claude.SPEC.name: claude.SPEC, codex.SPEC.name: codex.SPEC} +RUNTIMES = tuple(SPECS) + +__all__ = [ + "Plan", + "RUNTIMES", + "Result", + "RuntimeSpec", + "SPECS", + "SetupError", + "Step", + "apply_plan", + "detect_repo", + "known_template_digests", + "known_workflow_digests", + "lines", + "plan_setup", + "run_setup", + "script_text", + "template_digest", + "to_json", + "workflow_digest", + "workflow_text", +] diff --git a/src/agent_memory/setup/claude.py b/src/agent_memory/setup/claude.py new file mode 100644 index 0000000..580326a --- /dev/null +++ b/src/agent_memory/setup/claude.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory setup claude``: the Claude Code hook. + +Claude Code reads ``.claude/settings.json`` and runs each ``SessionStart`` +command with the ``startup`` matcher when a session begins, with the +repository as its working directory; what the command prints to stdout is +added to the session's context. So the script prints its manifest, and a +warning, as plain lines. The registration retires the two hook commands the +kernel used to install, and removes their scripts only when they are byte +for byte what it wrote; the kernel's envelope hook and anything custom stay. Beside the hook goes the +workflow file, a repository skill Claude Code loads on demand. +""" + +from __future__ import annotations + +from . import legacy +from .common import RuntimeSpec + +SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json" + +SPEC = RuntimeSpec( + name="claude", + settings_file=".claude/settings.json", + script_file=".claude/hooks/agent-memory-pull.sh", + event="SessionStart", + matcher="startup", + timeout=30, + workflow_file=".claude/skills/agent-memory/SKILL.md", + report_body=" printf '%s\\n' \"$1\"", + fresh_settings={"$schema": SETTINGS_SCHEMA}, + legacy_registrations=legacy.CLAUDE_LEGACY_REGISTRATIONS, + legacy_files=legacy.CLAUDE_LEGACY_FILES, +) diff --git a/src/agent_memory/setup/codex.py b/src/agent_memory/setup/codex.py new file mode 100644 index 0000000..1112e8a --- /dev/null +++ b/src/agent_memory/setup/codex.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory setup codex``: the Codex hook. + +Codex reads ``.codex/hooks.json``; a ``SessionStart`` entry whose matcher is +``^startup$`` runs when a session begins, and the command answers with a +JSON envelope whose ``additionalContext`` the runtime adds to the session. +The script therefore reports through that envelope, and the ``startup`` verb +renders one for this runtime. The entry is the memory half only: it sits +beside the kernel's ``session-init --hook`` entry, which keeps verifying +protocol files and status, and only that entry's ``--pull-memory`` flag is +retired, so memory is pulled once. Its ``additionalContextLimit`` belongs +to it and is never touched. Beside the hook goes the workflow file, a +repository skill under ``.agents/skills/``, where Codex discovers them. +""" + +from __future__ import annotations + +from . import legacy +from .common import RuntimeSpec + +REPORT_BODY = ( + " printf '{\"continue\": true, \"systemMessage\": \"%s\", \"hookSpecificOutput\": " + "{\"hookEventName\": \"SessionStart\", \"additionalContext\": \"%s\"}}\\n' \"$1\" \"$1\"" +) + +SPEC = RuntimeSpec( + name="codex", + settings_file=".codex/hooks.json", + script_file=".codex/hooks/agent-memory-pull.sh", + event="SessionStart", + matcher="^startup$", + timeout=60, + workflow_file=".agents/skills/agent-memory/SKILL.md", + report_body=REPORT_BODY, + hook_fields={"statusMessage": "Pulling agent memory"}, + fresh_settings={"description": "agent-memory startup: pull the memory home and list the files to read."}, + legacy_flag=(legacy.CODEX_SESSION_INIT_PREFIX, legacy.CODEX_LEGACY_PULL_FLAG), +) diff --git a/src/agent_memory/setup/common.py b/src/agent_memory/setup/common.py new file mode 100644 index 0000000..fc3339d --- /dev/null +++ b/src/agent_memory/setup/common.py @@ -0,0 +1,1015 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Install a runtime's memory hook: ``agent-memory setup ``. + +One planner and one applier serve every runtime; a :class:`RuntimeSpec` +carries what differs (the settings file, the script path, the hook event +and matcher, the entry's fields, and the legacy commands to retire). The +plan is computed in full, against the repository as it is, before a byte is +written, and ``--dry-run`` prints exactly that plan. Every step is +idempotent, so an interrupted apply is resumed by running setup again. + +The grammar, each rule pinned by a test: + +1. The hook script is written from a shipped template. A file already there + is regenerated only while its digest matches a template this or an earlier + version shipped; any other content is a named conflict and is kept. A + template that lost its execute bit is made executable again, and a script + that cannot be made runnable is never registered. +2. The registration is added once, by exact command; an entry that carries + it is never edited, wherever it sits. +3. Legacy registrations are retired by exact command, and a legacy flag is + removed from one simple command only, never from a compound one. Their + scripts are removed only when every one of these holds: the settings file + was read in full, no command anywhere in it still mentions the script, + the digest is the template that wrote it, and no symlink lies between the + repository and the file. Anything else is kept and named. +4. Nothing is written through a symlink: a linked script, settings file or + hook directory is reported with its target and left to its owner. +5. A receipt in the home records what was installed, at which version and + digest, and what was retired, so a later run and a fleet census can tell + this tool's files from everybody else's. +6. No push hook is ever installed; a session's end is the runtime's own. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import os +import re +import shlex +import stat +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple + +from .. import __version__, layout, workflow + +RECEIPT_SCHEMA_VERSION = 1 +SCRIPT_MODE = 0o755 +#: The verb the generated hook runs; it pulls, then prints the bounded read manifest. +MANIFEST_VERB = "startup" + +WRITE_SCRIPT = "write_script" +REGENERATE_SCRIPT = "regenerate_script" +CHMOD_SCRIPT = "chmod_script" +SCRIPT_IN_PLACE = "script_in_place" +SCRIPT_CONFLICT = "script_conflict" +CREATE_SETTINGS = "create_settings" +REGISTER = "register" +REGISTERED = "registered" +REGISTRATION_HELD = "registration_held" +RETIRE_REGISTRATION = "retire_registration" +STRIP_FLAG = "strip_flag" +KEEP_FLAG = "keep_flag" +SETTINGS_CONFLICT = "settings_conflict" +REMOVE_LEGACY_FILE = "remove_legacy_file" +KEEP_LEGACY_FILE = "keep_legacy_file" +WRITE_WORKFLOW = "write_workflow" +REGENERATE_WORKFLOW = "regenerate_workflow" +WORKFLOW_IN_PLACE = "workflow_in_place" +WORKFLOW_CONFLICT = "workflow_conflict" + +CHANGING_ACTIONS = frozenset( + { + WRITE_SCRIPT, + REGENERATE_SCRIPT, + CHMOD_SCRIPT, + WRITE_WORKFLOW, + REGENERATE_WORKFLOW, + CREATE_SETTINGS, + REGISTER, + RETIRE_REGISTRATION, + STRIP_FLAG, + REMOVE_LEGACY_FILE, + } +) +CONFLICT_ACTIONS = frozenset({SCRIPT_CONFLICT, SETTINGS_CONFLICT, WORKFLOW_CONFLICT}) + +#: How each action reads in the report: (marker, done, planned). +VERBS: Dict[str, Tuple[str, str, str]] = { + WRITE_SCRIPT: ("+", "written", "would write"), + REGENERATE_SCRIPT: ("~", "regenerated", "would regenerate"), + CHMOD_SCRIPT: ("~", "made executable", "would make executable"), + SCRIPT_IN_PLACE: ("=", "in place", "in place"), + SCRIPT_CONFLICT: ("!", "conflict, kept", "conflict, would keep"), + CREATE_SETTINGS: ("+", "created", "would create"), + REGISTER: ("+", "registered", "would register"), + REGISTERED: ("=", "already registered", "already registered"), + REGISTRATION_HELD: ("!", "not registered", "would not register"), + RETIRE_REGISTRATION: ("-", "retired", "would retire"), + STRIP_FLAG: ("-", "retired flag", "would retire flag"), + KEEP_FLAG: ("!", "flag kept", "would keep flag"), + SETTINGS_CONFLICT: ("!", "conflict, not edited", "conflict, would not edit"), + REMOVE_LEGACY_FILE: ("-", "removed", "would remove"), + KEEP_LEGACY_FILE: ("!", "kept", "would keep"), + WRITE_WORKFLOW: ("+", "written", "would write"), + REGENERATE_WORKFLOW: ("~", "regenerated", "would regenerate"), + WORKFLOW_IN_PLACE: ("=", "in place", "in place"), + WORKFLOW_CONFLICT: ("!", "conflict, kept", "conflict, would keep"), +} + +#: Tokens the shell reads as operators; a command carrying one is compound and is never edited. +_SHELL_OPERATOR_CHARS = frozenset("();<>|&$`") + +SCRIPT_TEMPLATE = """\ +#!/usr/bin/env bash +# agent-memory SessionStart hook for {name}; managed by `agent-memory setup {name}`. +# A rerun regenerates this file only while its digest matches a shipped template; +# an edited file is reported as a conflict and kept as it is. +set -u +export AGENT_MEMORY_AGENT="${{AGENT_MEMORY_AGENT:-{name}}}" +report() {{ +{report_body} +}} +if ! command -v agent-memory >/dev/null 2>&1; then + report "agent-memory: command not found; memory not pulled and no startup manifest (install agent-memory-cli)." + exit 0 +fi +agent-memory {verb} --runtime {name} --pull || report "agent-memory: {verb} exited $?; local memory may be stale." +exit 0 +""" + + +class SetupError(Exception): + """A precondition failed; nothing was changed.""" + + +@dataclass(frozen=True) +class RuntimeSpec: + """What one runtime's hook installation looks like.""" + + name: str + #: The runtime's hook settings file, repository-relative. + settings_file: str + #: The hook script, repository-relative; it is also the registered command. + script_file: str + event: str + matcher: str + timeout: int + #: Shell lines of the script's ``report`` function: how a warning reaches the runtime. + report_body: str + #: Extra fields on the hook entry (a status message, say). + hook_fields: Mapping[str, Any] = field(default_factory=dict) + #: Top-level keys a settings file created from scratch starts with. + fresh_settings: Mapping[str, Any] = field(default_factory=dict) + #: Registrations retired by exact command, per event. + legacy_registrations: Mapping[str, Tuple[str, ...]] = field(default_factory=dict) + #: Files removed only on a digest match, per repository-relative path. + legacy_files: Mapping[str, Tuple[str, ...]] = field(default_factory=dict) + #: ``(argv prefix, flag)``: the flag is removed from any hook command with that prefix. + legacy_flag: Optional[Tuple[Tuple[str, ...], str]] = None + #: Digests of this runtime's script as earlier versions shipped it; those regenerate. + previous_template_digests: Tuple[str, ...] = () + #: The workflow file, repository-relative: this runtime's thin wrapper of the shipped workflow text. + workflow_file: str = "" + #: Digests of this runtime's workflow file as earlier versions shipped it; those regenerate. + previous_workflow_digests: Tuple[str, ...] = () + + def hook(self) -> Dict[str, Any]: + return {"type": "command", "command": self.script_file, "timeout": self.timeout, **dict(self.hook_fields)} + + def entry(self) -> Dict[str, Any]: + return {"matcher": self.matcher, "hooks": [self.hook()]} + + +def script_text(spec: RuntimeSpec) -> str: + """The hook script for ``spec``, byte for byte what setup writes.""" + return SCRIPT_TEMPLATE.format(name=spec.name, report_body=spec.report_body, verb=MANIFEST_VERB) + + +def template_digest(spec: RuntimeSpec) -> str: + return _digest_bytes(script_text(spec).encode("utf-8")) + + +def known_template_digests(spec: RuntimeSpec) -> Tuple[str, ...]: + """Every digest a file at the script path may carry and still count as this tool's.""" + return (template_digest(spec), *spec.previous_template_digests) + + +def workflow_text(spec: RuntimeSpec) -> str: + """The workflow file for ``spec``, byte for byte what setup writes: the shipped text with the runtime's name.""" + return workflow.workflow_text(spec.name) + + +def workflow_digest(spec: RuntimeSpec) -> str: + return _digest_bytes(workflow_text(spec).encode("utf-8")) + + +def known_workflow_digests(spec: RuntimeSpec) -> Tuple[str, ...]: + """Every digest a file at the workflow path may carry and still count as this tool's.""" + return (workflow_digest(spec), *spec.previous_workflow_digests) + + +@dataclass(frozen=True) +class Step: + """One planned action on one repository-relative path.""" + + action: str + path: str + detail: str = "" + + @property + def conflict(self) -> bool: + return self.action in CONFLICT_ACTIONS + + @property + def changes(self) -> bool: + return self.action in CHANGING_ACTIONS + + +@dataclass +class Plan: + """Everything an apply will do, computed before it does any of it.""" + + spec: RuntimeSpec + repo: Path + home: Path + steps: List[Step] + #: Bytes to place at the script path; ``None`` when the script is not written. + script_write: Optional[bytes] + #: Whether the script write replaces an earlier template (else it creates the file). + script_regenerate: bool + #: Whether a template already at the script path only needs its execute bit back. + script_chmod: bool + #: Text to write to the settings file; ``None`` when it is not written. + settings_write: Optional[str] + removals: List[Path] + receipt_path: Path + receipt: Dict[str, Any] + receipt_write: bool + #: Bytes to place at the workflow path; ``None`` when it is not written. + workflow_write: Optional[bytes] = None + #: Whether the workflow write replaces an earlier template (else it creates the file). + workflow_regenerate: bool = False + + @property + def conflicts(self) -> List[Step]: + return [step for step in self.steps if step.conflict] + + @property + def changed(self) -> bool: + return any(step.changes for step in self.steps) or self.receipt_write + + +@dataclass(frozen=True) +class Result: + plan: Plan + dry_run: bool + + +# --- planning --------------------------------------------------------------- + + +def plan_setup( + spec: RuntimeSpec, + repo: Path, + home: Path, + *, + version: str = __version__, + now: Optional[dt.datetime] = None, +) -> Plan: + """Inspect ``repo`` and ``home`` and decide every step; nothing is written.""" + repo = _absolute(repo) + home = _absolute(home) + if not repo.is_dir(): + raise SetupError(f"{repo} is not a directory") + if not home.is_dir(): + raise SetupError(f"memory home {home} does not exist; create it with `agent-memory init` first") + + steps: List[Step] = [] + script = _plan_script(spec, repo, steps) + settings_write, data_after, settings_state = _plan_settings(spec, repo, steps, script_present=script.present) + removals = _plan_legacy_files(spec, repo, data_after, settings_state, steps) + flow = _plan_workflow(spec, repo, steps) + + receipt_path = home / layout.SETUP_DIR / spec.name / f"{_repo_key(repo)}.json" + previous = _load_json(receipt_path) + receipt = _receipt( + spec, + repo, + home, + version=version, + steps=steps, + script_state=script.state, + script_digest=script.digest, + script_mode=script.mode, + settings_state=settings_state, + settings_write=settings_write, + workflow_state=flow.state, + workflow_digest_value=flow.digest, + previous=previous if isinstance(previous, dict) else None, + now=now or dt.datetime.now(dt.timezone.utc), + ) + receipt_write = not isinstance(previous, dict) or _without_stamp(previous) != _without_stamp(receipt) + return Plan( + spec, + repo, + home, + steps, + script.write, + script.regenerate, + script.chmod, + settings_write, + removals, + receipt_path, + receipt, + receipt_write, + workflow_write=flow.write, + workflow_regenerate=flow.regenerate, + ) + + +@dataclass(frozen=True) +class _ScriptPlan: + #: Bytes to place at the script path; ``None`` when nothing is written there. + write: Optional[bytes] + regenerate: bool + #: ``written``, ``regenerated``, ``in_place`` or ``conflict``. + state: str + #: The digest the path will carry after the apply, when it can be known. + digest: Optional[str] + #: Whether a runnable file will be at the path after the apply, so registering it makes sense. + present: bool + #: The permission bits the path will carry after the apply; ``None`` when nothing is there. + mode: Optional[int] = None + #: Whether the apply only restores the execute bit of a template already in place. + chmod: bool = False + + +def _plan_script(spec: RuntimeSpec, repo: Path, steps: List[Step]) -> _ScriptPlan: + rel = spec.script_file + script = repo / rel + text = script_text(spec).encode("utf-8") + current = template_digest(spec) + known = known_template_digests(spec) + + link = _linked_component(repo, script) + if link is not None: + target = _realpath(link) + content = _digest_file(script) + mode = _mode_of(script, None) + if content in known and _executable(mode): + steps.append( + Step(SCRIPT_IN_PLACE, rel, f"through the symlink {_rel(repo, link)} -> {target}; shared source left as it is") + ) + return _ScriptPlan(None, False, "in_place", content, True, mode) + if content is None: + what = "is missing or unreadable there" + elif content not in known: + what = "differs from every shipped template" + else: + what = f"is a shipped template but not executable (mode {_octal(mode)}), and its mode is not changed through the link" + steps.append( + Step( + SCRIPT_CONFLICT, + rel, + f"{_rel(repo, link)} is a symlink to {target}; the shared source {what} and is not written through", + ) + ) + return _ScriptPlan(None, False, "conflict", content, content is not None and _executable(mode), mode) + if os.path.lexists(script): + if not script.is_file(): + steps.append(Step(SCRIPT_CONFLICT, rel, "exists and is not a regular file")) + return _ScriptPlan(None, False, "conflict", None, False) + content = _digest_file(script) + mode = _mode_of(script, None) + if content is None: + steps.append(Step(SCRIPT_CONFLICT, rel, "exists and cannot be read")) + return _ScriptPlan(None, False, "conflict", None, False, mode) + if content == current: + if _executable(mode): + steps.append(Step(SCRIPT_IN_PLACE, rel, f"digest {_short(current)} is the shipped template")) + return _ScriptPlan(None, False, "in_place", content, True, mode) + steps.append(Step(CHMOD_SCRIPT, rel, f"digest {_short(current)} is the shipped template at mode {_octal(mode)}")) + return _ScriptPlan(None, False, "in_place", content, True, SCRIPT_MODE, chmod=True) + if content in known: + steps.append(Step(REGENERATE_SCRIPT, rel, f"digest {_short(content)} is an earlier template; now {_short(current)}")) + return _ScriptPlan(text, True, "regenerated", current, True, SCRIPT_MODE) + steps.append(Step(SCRIPT_CONFLICT, rel, f"digest {_short(content)} matches no shipped template; edited, kept as it is")) + return _ScriptPlan(None, False, "conflict", content, _executable(mode), mode) + steps.append(Step(WRITE_SCRIPT, rel, f"template digest {_short(current)}")) + return _ScriptPlan(text, False, "written", current, True, SCRIPT_MODE) + + +@dataclass(frozen=True) +class _FilePlan: + #: Bytes to place at the path; ``None`` when nothing is written there. + write: Optional[bytes] + regenerate: bool + #: ``written``, ``regenerated``, ``in_place``, ``conflict``, or ``absent`` when the runtime has no such file. + state: str + digest: Optional[str] + + +def _plan_workflow(spec: RuntimeSpec, repo: Path, steps: List[Step]) -> _FilePlan: + """The workflow file follows the script's rules without the execute bit: written once, regenerated only + while its digest is a shipped template, kept and named as a conflict when edited, never written through a link.""" + if not spec.workflow_file: + return _FilePlan(None, False, "absent", None) + rel = spec.workflow_file + target = repo / rel + text = workflow_text(spec).encode("utf-8") + current = workflow_digest(spec) + known = known_workflow_digests(spec) + + link = _linked_component(repo, target) + if link is not None: + content = _digest_file(target) + if content in known: + steps.append( + Step(WORKFLOW_IN_PLACE, rel, f"through the symlink {_rel(repo, link)} -> {_realpath(link)}; shared source left as it is") + ) + return _FilePlan(None, False, "in_place", content) + what = "is missing or unreadable there" if content is None else "differs from every shipped template" + steps.append( + Step(WORKFLOW_CONFLICT, rel, f"{_rel(repo, link)} is a symlink to {_realpath(link)}; the shared source {what} and is not written through") + ) + return _FilePlan(None, False, "conflict", content) + if os.path.lexists(target): + if not target.is_file(): + steps.append(Step(WORKFLOW_CONFLICT, rel, "exists and is not a regular file")) + return _FilePlan(None, False, "conflict", None) + content = _digest_file(target) + if content is None: + steps.append(Step(WORKFLOW_CONFLICT, rel, "exists and cannot be read")) + return _FilePlan(None, False, "conflict", None) + if content == current: + steps.append(Step(WORKFLOW_IN_PLACE, rel, f"digest {_short(current)} is the shipped template")) + return _FilePlan(None, False, "in_place", content) + if content in known: + steps.append(Step(REGENERATE_WORKFLOW, rel, f"digest {_short(content)} is an earlier template; now {_short(current)}")) + return _FilePlan(text, True, "regenerated", current) + steps.append(Step(WORKFLOW_CONFLICT, rel, f"digest {_short(content)} matches no shipped template; edited, kept as it is")) + return _FilePlan(None, False, "conflict", content) + steps.append(Step(WRITE_WORKFLOW, rel, f"template digest {_short(current)}")) + return _FilePlan(text, False, "written", current) + + +def _executable(mode: Optional[int]) -> bool: + """Whether the owner, who runs the hook, may execute a file of ``mode``.""" + return mode is not None and bool(mode & stat.S_IXUSR) + + +def _plan_settings( + spec: RuntimeSpec, repo: Path, steps: List[Step], *, script_present: bool +) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + rel = spec.settings_file + settings = repo / rel + + link = _linked_component(repo, settings) + if link is not None: + steps.append( + Step( + SETTINGS_CONFLICT, + rel, + f"{_rel(repo, link)} is a symlink to {_realpath(link)}; the shared source is not edited, " + f"register {spec.script_file} there yourself", + ) + ) + return None, _load_json(settings), "conflict" + + before: Optional[str] = None + created = False + if os.path.lexists(settings): + if not settings.is_file(): + steps.append(Step(SETTINGS_CONFLICT, rel, "exists and is not a regular file")) + return None, None, "conflict" + try: + before = settings.read_text(encoding="utf-8") + data = json.loads(before) + except (OSError, ValueError) as exc: + steps.append(Step(SETTINGS_CONFLICT, rel, f"cannot be read as JSON: {exc}")) + return None, None, "conflict" + if not isinstance(data, dict) or ("hooks" in data and not isinstance(data["hooks"], dict)): + steps.append(Step(SETTINGS_CONFLICT, rel, "expected a JSON object whose 'hooks' is an object")) + return None, None, "conflict" + else: + data = dict(spec.fresh_settings) + created = True + steps.append(Step(CREATE_SETTINGS, rel, "")) + + hooks: Dict[str, Any] = data.setdefault("hooks", {}) + own = hooks.get(spec.event) + if own is not None and not isinstance(own, list): + steps.append(Step(SETTINGS_CONFLICT, rel, f"hooks.{spec.event} is not a list")) + return None, data, "conflict" + + changed = created + # The legacy hooks go only once their replacement can run: a repository is never left with no memory hook. + if script_present: + for event, commands in spec.legacy_registrations.items(): + entries = hooks.get(event) + if not isinstance(entries, list): + continue + for command in commands: + if _remove_command(entries, command): + steps.append(Step(RETIRE_REGISTRATION, rel, f"{event}: {command}")) + changed = True + if not entries: + del hooks[event] + if spec.legacy_flag is not None and isinstance(own, list): + prefix, flag = spec.legacy_flag + for old, new, why in _strip_flag(own, prefix, flag): + if new is None: + steps.append(Step(KEEP_FLAG, rel, f"{spec.event}: {flag} left in `{old}`; {why}")) + continue + steps.append(Step(STRIP_FLAG, rel, f"{spec.event}: {flag} removed from `{old}`, now `{new}`")) + changed = True + + entries = hooks.setdefault(spec.event, []) + label = f"{spec.event} {spec.matcher!r}: {spec.script_file}" + if _command_registered(entries, spec.script_file): + steps.append(Step(REGISTERED, rel, label)) + elif not script_present: + steps.append( + Step( + REGISTRATION_HELD, + rel, + f"{label}; the script is not in place, so nothing is registered to run it and the legacy hooks stay", + ) + ) + else: + entries.append(spec.entry()) + steps.append(Step(REGISTER, rel, label)) + changed = True + if not entries: + del hooks[spec.event] + + if not changed: + return None, data, "unchanged" + after = _dumps(data) + if after == before: + return None, data, "unchanged" + return after, data, "created" if created else "updated" + + +def _plan_legacy_files( + spec: RuntimeSpec, repo: Path, data_after: Optional[Dict[str, Any]], settings_state: str, steps: List[Step] +) -> List[Path]: + """Legacy scripts to remove: only a local, unedited template that the fully read settings no longer mention. + + Every uncertainty keeps the file: a settings file that could not be read in full + (so the registrations are unknown), a command anywhere in it that still names + the script, however it is invoked, a symlink between the repository and the file, + a digest that is not the template's. + """ + removals: List[Path] = [] + inspected = settings_state != "conflict" and isinstance(data_after, dict) + hooks = data_after.get("hooks") if inspected and isinstance(data_after, dict) else None + for rel, digests in spec.legacy_files.items(): + path = repo / rel + if not os.path.lexists(path): + continue + link = _linked_component(repo, path) + if link is not None: + steps.append(Step(KEEP_LEGACY_FILE, rel, f"{_rel(repo, link)} is a symlink to {_realpath(link)}; not removed")) + continue + if not path.is_file(): + steps.append(Step(KEEP_LEGACY_FILE, rel, "not a regular file; not removed")) + continue + digest = _digest_file(path) + if digest is None or digest not in digests: + shown = "unreadable" if digest is None else f"digest {_short(digest)}" + steps.append(Step(KEEP_LEGACY_FILE, rel, f"{shown} is not the template that installed it; edited, kept")) + continue + if not inspected: + steps.append(Step(KEEP_LEGACY_FILE, rel, f"{spec.settings_file} could not be read in full; still registered for all this tool knows")) + continue + mention = _mentioned_anywhere(hooks, rel) + if mention is not None: + what = "still registered" if mention == rel else f"still named by `{mention}`" + steps.append(Step(KEEP_LEGACY_FILE, rel, f"{what}; not removed")) + continue + steps.append(Step(REMOVE_LEGACY_FILE, rel, f"digest {_short(digest)} is the legacy template")) + removals.append(path) + return removals + + +# --- the receipt ------------------------------------------------------------ + + +def _receipt( + spec: RuntimeSpec, + repo: Path, + home: Path, + *, + version: str, + steps: Sequence[Step], + script_state: str, + script_digest: Optional[str], + script_mode: Optional[int], + settings_state: str, + settings_write: Optional[str], + workflow_state: str, + workflow_digest_value: Optional[str], + previous: Optional[Dict[str, Any]], + now: dt.datetime, +) -> Dict[str, Any]: + """The receipt as it stands after the apply: states, not actions, so a no-op rerun leaves it as it is. + + ``retired`` is a history: what earlier runs retired stays recorded, and this run's + retirements are added once. + """ + script = repo / spec.script_file + settings = repo / spec.settings_file + if settings_write is not None: + settings_digest: Optional[str] = _digest_bytes(settings_write.encode("utf-8")) + else: + settings_digest = _digest_file(settings) + if settings_state == "conflict": + registration_state = "conflict" + elif any(step.action == REGISTRATION_HELD for step in steps): + registration_state = "held" + else: + registration_state = "registered" + managed = [ + { + "path": spec.script_file, + "resolved": _realpath(script), + "symlink": _linked_component(repo, script) is not None, + "state": "conflict" if script_state == "conflict" else "installed", + "digest": script_digest, + "template_digest": template_digest(spec), + "mode": _octal(script_mode) if script_mode is not None else None, + }, + { + "path": spec.settings_file, + "resolved": _realpath(settings), + "symlink": _linked_component(repo, settings) is not None, + "state": registration_state, + "digest": settings_digest, + "registration": {"event": spec.event, "matcher": spec.matcher, "command": spec.script_file}, + }, + ] + if spec.workflow_file: + flow = repo / spec.workflow_file + managed.append( + { + "path": spec.workflow_file, + "resolved": _realpath(flow), + "symlink": _linked_component(repo, flow) is not None, + "state": "conflict" if workflow_state == "conflict" else "installed", + "digest": workflow_digest_value, + "template_digest": workflow_digest(spec), + } + ) + retired: List[Dict[str, Any]] = [] + if previous is not None and isinstance(previous.get("retired"), list): + retired.extend(item for item in previous["retired"] if isinstance(item, dict)) + for step in steps: + if step.action in (RETIRE_REGISTRATION, STRIP_FLAG): + item: Dict[str, Any] = {"kind": "registration", "path": step.path, "detail": step.detail} + elif step.action in (REMOVE_LEGACY_FILE, KEEP_LEGACY_FILE): + item = {"kind": "file", "path": step.path, "removed": step.action == REMOVE_LEGACY_FILE, "detail": step.detail} + else: + continue + if item not in retired: + retired.append(item) + return { + "schema_version": RECEIPT_SCHEMA_VERSION, + "tool": "agent-memory", + "version": version, + "runtime": spec.name, + "repo": str(repo), + "repo_resolved": _realpath(repo), + "home": str(home), + "written_at_utc": _iso(now), + "managed": managed, + "retired": retired, + "conflicts": [{"path": step.path, "detail": step.detail} for step in steps if step.conflict], + } + + +def _without_stamp(receipt: Dict[str, Any]) -> Dict[str, Any]: + return {key: value for key, value in receipt.items() if key != "written_at_utc"} + + +def _repo_key(repo: Path) -> str: + return hashlib.sha256(_realpath(repo).encode("utf-8")).hexdigest()[:16] + + +# --- applying --------------------------------------------------------------- + + +def apply_plan(plan: Plan) -> None: + """Perform the plan's writes in a fixed order; each is safe to repeat after an interruption.""" + spec = plan.spec + if plan.script_write is not None: + script = plan.repo / spec.script_file + script.parent.mkdir(parents=True, exist_ok=True) + if plan.script_regenerate: + _replace_file(script, plan.script_write, SCRIPT_MODE) + else: + _create_file(script, plan.script_write, SCRIPT_MODE) + elif plan.script_chmod: + _chmod_file(plan.repo / spec.script_file, SCRIPT_MODE) + if plan.workflow_write is not None: + flow = plan.repo / spec.workflow_file + flow.parent.mkdir(parents=True, exist_ok=True) + if plan.workflow_regenerate: + _replace_file(flow, plan.workflow_write, _mode_of(flow, 0o644)) + else: + _create_file(flow, plan.workflow_write, 0o644) + if plan.settings_write is not None: + settings = plan.repo / spec.settings_file + settings.parent.mkdir(parents=True, exist_ok=True) + _replace_file(settings, plan.settings_write.encode("utf-8"), _mode_of(settings, 0o644)) + for path in plan.removals: + try: + path.unlink() + except FileNotFoundError: + pass + if plan.receipt_write: + plan.receipt_path.parent.mkdir(parents=True, exist_ok=True) + _replace_file(plan.receipt_path, _dumps(plan.receipt).encode("utf-8"), _mode_of(plan.receipt_path, 0o644)) + + +def run_setup(spec: RuntimeSpec, repo: Path, home: Path, *, dry_run: bool = False, version: str = __version__) -> Result: + """Plan, and unless ``dry_run``, apply.""" + plan = plan_setup(spec, repo, home, version=version) + if not dry_run: + apply_plan(plan) + return Result(plan, dry_run) + + +def detect_repo(start: Path) -> Path: + """The nearest directory at or above ``start`` holding a ``.git`` entry, else ``start`` itself.""" + start = _absolute(start) + for directory in (start, *start.parents): + if os.path.lexists(directory / ".git"): + return directory + return start + + +# --- reporting -------------------------------------------------------------- + + +def lines(result: Result) -> List[str]: + plan = result.plan + out = [f"agent-memory setup {plan.spec.name}: {plan.repo}", f"home: {plan.home}"] + for step in plan.steps: + marker, done, planned = VERBS[step.action] + verb = planned if result.dry_run else done + detail = f" ({step.detail})" if step.detail else "" + out.append(f" {marker} {step.path}: {verb}{detail}") + receipt_verb = ("would write" if result.dry_run else "written") if plan.receipt_write else "unchanged" + out.append(f"receipt: {plan.receipt_path} ({receipt_verb})") + if plan.conflicts: + out.append(f"conflicts: {len(plan.conflicts)}; nothing marked ! was written. Resolve them and run setup again.") + if result.dry_run: + out.append("dry run: nothing was written.") + elif not plan.changed: + out.append("nothing to do: the hook and the workflow file are installed and in place.") + return out + + +def to_json(result: Result) -> Dict[str, Any]: + plan = result.plan + return { + "schema_version": 1, + "action": "setup", + "runtime": plan.spec.name, + "repo": str(plan.repo), + "home": str(plan.home), + "dry_run": result.dry_run, + "changed": plan.changed, + "steps": [ + {"action": step.action, "path": step.path, "detail": step.detail, "changes": step.changes, "conflict": step.conflict} + for step in plan.steps + ], + "conflicts": [step.path for step in plan.conflicts], + "receipt": {"path": str(plan.receipt_path), "written": plan.receipt_write and not result.dry_run}, + } + + +# --- hook-list surgery, shared by every runtime ----------------------------- + + +def _command_registered(entries: Sequence[Any], command: str) -> bool: + for entry in entries: + for hook in _hooks_of(entry): + if hook.get("command") == command: + return True + return False + + +def _mentioned_anywhere(hooks: Any, path: str) -> Optional[str]: + """The first hook command, under any event, whose text names ``path`` or its basename; ``None`` when none does. + + A custom wrapper (``bash .claude/hooks/x.sh``) still runs the file, so any mention + keeps it; only an exact command is ever retired. + """ + if not isinstance(hooks, dict): + return None + names = (path, os.path.basename(path)) + for entries in hooks.values(): + if not isinstance(entries, list): + continue + for entry in entries: + for hook in _hooks_of(entry): + command = hook.get("command") + if isinstance(command, str) and any(name in command for name in names): + return command + return None + + +def _remove_command(entries: List[Any], command: str) -> bool: + """Drop every hook whose command is exactly ``command``; entries left empty go too. True when anything changed.""" + changed = False + retained: List[Any] = [] + for entry in entries: + hooks = _hooks_of(entry) + if not hooks: + retained.append(entry) + continue + kept = [hook for hook in entry["hooks"] if not (isinstance(hook, dict) and hook.get("command") == command)] + if len(kept) == len(entry["hooks"]): + retained.append(entry) + continue + changed = True + if kept: + updated = dict(entry) + updated["hooks"] = kept + retained.append(updated) + if changed: + entries[:] = retained + return changed + + +def _strip_flag(entries: Sequence[Any], prefix: Sequence[str], flag: str) -> Iterator[Tuple[str, Optional[str], str]]: + """Remove ``flag`` from every hook command that starts with ``prefix``; yields (before, after, reason). + + The edit is made on the command text as written, so quoting and every other + byte survive; ``after`` is ``None``, with the reason, when the command is not + one simple command (an operator, redirection or substitution makes it compound) + or the flag is not a bare word in it. Such a command is left exactly as it is. + """ + for entry in entries: + for hook in _hooks_of(entry): + command = hook.get("command") + if not isinstance(command, str): + continue + parsed = _tokens(command) + if parsed is None: + continue + argv, simple = parsed + if argv[: len(prefix)] != list(prefix) or flag not in argv: + continue + if not simple: + yield command, None, "not one simple command; edited by hand if the flag should go" + continue + rewritten = re.sub(rf"\s+{re.escape(flag)}(?!\S)", "", command) + try: + left = shlex.split(rewritten) + except ValueError: + left = None + if left != [arg for arg in argv if arg != flag]: + yield command, None, "the flag is not a bare word in it" + continue + hook["command"] = rewritten + yield command, rewritten, "" + + +def _tokens(command: str) -> Optional[Tuple[List[str], bool]]: + """``(tokens, simple)``: the shell's tokens of ``command``, and whether they make one simple command. + + Operators, redirections and substitutions (``&&``, ``;``, ``|``, ``>``, ``$(``, + backticks) are read as the shell would, so a flag after a ``;`` is still seen + and the command is still known to be compound. A newline separates commands + too, and a lexer folds it into whitespace, so any multi-line text is compound + by rule. ``None`` on unbalanced quotes. + """ + try: + lexer = shlex.shlex(command, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) + words = shlex.split(command) + except ValueError: + return None + simple = ( + tokens == words + and "`" not in command + and "\n" not in command + and "\r" not in command + and not any(token and set(token) <= _SHELL_OPERATOR_CHARS for token in tokens) + ) + return tokens, simple + + +def _hooks_of(entry: Any) -> List[Dict[str, Any]]: + if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list): + return [] + return [hook for hook in entry["hooks"] if isinstance(hook, dict)] + + +# --- files ------------------------------------------------------------------ + + +def _linked_component(repo: Path, path: Path) -> Optional[Path]: + """The first symlink on the way from ``repo`` down to ``path`` (``path`` included), or ``None``.""" + current = repo + for part in path.relative_to(repo).parts: + current = current / part + if current.is_symlink(): + return current + return None + + +def _create_file(path: Path, data: bytes, mode: int) -> None: + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + except FileExistsError as exc: + raise SetupError(f"{path} appeared while setup was running; run setup again") from exc + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.chmod(path, mode) + + +def _replace_file(path: Path, data: bytes, mode: int) -> None: + handle = tempfile.NamedTemporaryFile("wb", dir=str(path.parent), prefix=f".{path.name}.", delete=False) + with handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + temp = Path(handle.name) + try: + os.chmod(temp, mode) + os.replace(temp, path) + except OSError: + try: + temp.unlink() + except OSError: + pass + raise + + +def _chmod_file(path: Path, mode: int) -> None: + """Set ``mode`` on the regular file at ``path`` itself, never on whatever a link there points at.""" + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + try: + os.fchmod(fd, mode) + finally: + os.close(fd) + + +def _mode_of(path: Path, default: Optional[int]) -> Optional[int]: + try: + return os.stat(path).st_mode & 0o777 + except OSError: + return default + + +def _digest_file(path: Path) -> Optional[str]: + try: + return _digest_bytes(path.read_bytes()) + except OSError: + return None + + +def _digest_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _short(digest: Optional[str]) -> str: + return (digest or "?")[:12] + + +def _octal(mode: Optional[int]) -> str: + return f"{mode:04o}" if mode is not None else "unknown" + + +def _load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _dumps(data: Any) -> str: + return json.dumps(data, indent=2, sort_keys=True) + "\n" + + +def _realpath(path: Path) -> str: + return os.path.realpath(path) + + +def _rel(repo: Path, path: Path) -> str: + try: + return path.relative_to(repo).as_posix() + except ValueError: + return str(path) + + +def _absolute(value: Path) -> Path: + try: + return Path(value).expanduser().absolute() + except RuntimeError as exc: + raise SetupError(f"cannot expand {value}: {exc}") from exc + + +def _iso(moment: dt.datetime) -> str: + return moment.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/agent_memory/setup/legacy.py b/src/agent_memory/setup/legacy.py new file mode 100644 index 0000000..013cb3d --- /dev/null +++ b/src/agent_memory/setup/legacy.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""What earlier tooling installed, so ``setup`` can retire it by exact match. + +This is the one module in the package that names the kernel this tool grew +out of: the hook commands its setup registered and the exact scripts it +wrote. A registration is retired when its command equals one of these +strings; a script is removed only when its bytes match one of these +templates, digest for digest. Anything else at those paths is somebody's +own work: it is left alone and named in the report. +""" + +from __future__ import annotations + +import hashlib +from typing import Dict, Tuple + +CLAUDE_LEGACY_PULL_COMMAND = ".claude/hooks/oacp-memory-pull.sh" +CLAUDE_LEGACY_PUSH_COMMAND = ".claude/hooks/oacp-memory-push.sh" + +CLAUDE_LEGACY_PULL_SCRIPT = """\ +#!/usr/bin/env bash +# Claude hook event: SessionStart (startup) +set -u + +OACP_ROOT="${OACP_HOME:-$HOME/oacp}" +if [[ ! -f "$OACP_ROOT/.oacp-memory-repo" ]]; then + exit 0 +fi + +oacp memory pull --oacp-dir "$OACP_ROOT" || true +""" + +CLAUDE_LEGACY_PUSH_SCRIPT = """\ +#!/usr/bin/env bash +# Claude hook event: SessionEnd / wrap-up +set -u + +OACP_ROOT="${OACP_HOME:-$HOME/oacp}" +if [[ ! -f "$OACP_ROOT/.oacp-memory-repo" ]]; then + exit 0 +fi + +oacp memory push --oacp-dir "$OACP_ROOT" || true +""" + + +def digest(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +#: Registrations retired by exact command, per hook event. +CLAUDE_LEGACY_REGISTRATIONS: Dict[str, Tuple[str, ...]] = { + "SessionStart": (CLAUDE_LEGACY_PULL_COMMAND,), + "SessionEnd": (CLAUDE_LEGACY_PUSH_COMMAND,), +} + +#: Files removed only when their digest is one of these, per repository-relative path. +CLAUDE_LEGACY_FILES: Dict[str, Tuple[str, ...]] = { + CLAUDE_LEGACY_PULL_COMMAND: (digest(CLAUDE_LEGACY_PULL_SCRIPT),), + CLAUDE_LEGACY_PUSH_COMMAND: (digest(CLAUDE_LEGACY_PUSH_SCRIPT),), +} + +#: The codex startup command the kernel registers, and the flag that made it pull memory. +#: The entry stays (it verifies protocol files and status); only the flag is retired. +CODEX_SESSION_INIT_PREFIX: Tuple[str, ...] = ("oacp", "session-init", "--hook") +CODEX_LEGACY_PULL_FLAG = "--pull-memory" diff --git a/src/agent_memory/startup.py b/src/agent_memory/startup.py new file mode 100644 index 0000000..52799dd --- /dev/null +++ b/src/agent_memory/startup.py @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""The startup manifest: ``agent-memory startup --runtime ``. + +A session-start hook runs this once. It optionally pulls the home first, +then reports which memory files a session should read, in order, with each +file's readability, size and modification time as the pull left them, and +where the sync stands. +It never includes a file's content and never says a file was read: the +states are ``readable``, ``missing`` and ``unreadable``, nothing else, and +the manifest carries ``content_injected: false`` to say so. The list is +bounded by construction (the active project files, then the curated org +files, both from the layout table; ``events/``, ``debriefs/`` and +``archive/`` are excluded), and the rendered text is cut at a character +budget with a notice, so a hook can never flood a session. + +Output shapes: ``--json`` is the manifest itself, ``schema_version`` first; +the default is what the runtime's hook expects on stdout: plain text for +claude, whose session start takes stdout as context, and the hook JSON +envelope for codex, whose ``additionalContext`` carries the same text. +""" + +from __future__ import annotations + +import datetime as dt +import os +import stat +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from . import layout, sync +from .git_runner import GitRunner, run_git + +SCHEMA_VERSION = 1 +RUNTIME_CLAUDE = "claude" +RUNTIME_CODEX = "codex" +RUNTIMES = (RUNTIME_CLAUDE, RUNTIME_CODEX) +DEFAULT_MAX_CHARS = 8000 +#: The smallest budget the command line accepts; the notice fits in a few characters at any budget. +MIN_MAX_CHARS = 1 + +READABLE = "readable" +MISSING = "missing" +UNREADABLE = "unreadable" +RESULT_OK = "ok" +RESULT_DEGRADED = "degraded" +PULL_NOT_REQUESTED = "not_requested" +PULL_ERROR = "error" + + +def build_manifest( + home: Path, + *, + runtime: str, + project: Optional[str] = None, + pull: bool = False, + home_source: str = "flag", + project_source: Optional[str] = None, + notes: Sequence[str] = (), + runner: Optional[GitRunner] = None, + now: Optional[dt.datetime] = None, +) -> Dict[str, Any]: + """The manifest for ``home``: the ordered files with their states, the sync's standing, warnings. + + The pull, when requested, runs before any file is inspected, so sizes, + times and states describe the tree the session will read. ``notes`` are + warnings the caller already knows (why no project was resolved, say). + """ + if runtime not in RUNTIMES: + raise ValueError(f"unknown runtime {runtime!r}; one of {', '.join(RUNTIMES)}") + home = Path(home).expanduser().absolute() + warnings: List[str] = list(notes) + files: List[Dict[str, Any]] = [] + + sync_info, pull_info = _sync(home, pull, runner, warnings) + if project is not None: + try: + layout.validate_project_name(project) + except ValueError as exc: + warnings.append(f"project {project!r}: {exc}; its files are skipped") + project = None + if not home.is_dir(): + warnings.append(f"memory home {home} is not a directory; every file is missing") + if project is not None: + memory = layout.project_memory_dir(home, project) + files.extend(_entry(home, memory / name, layout.PROJECT.name, name) for name in layout.PROJECT.files) + else: + warnings.append("no project resolved; pass --project or bind the repository with `agent-memory init --repo .`") + org = layout.org_memory_dir(home) + files.extend(_entry(home, org / name, layout.ORG.name, name) for name in layout.ORG.files) + for entry in files: + if entry["state"] != READABLE: + reason = f" ({entry['error']})" if entry.get("error") else "" + warnings.append(f"{entry['relative']}: {entry['state']}{reason}") + + excluded = [f"{layout.ORG.pattern}/{sub}/" for sub in layout.ORG.dirs] + if project is not None: + memory_rel = layout.project_memory_dir(home, project).relative_to(home).as_posix() + excluded.extend(f"{memory_rel}/{sub}/" for sub in layout.PROJECT.dirs) + + moment = now or dt.datetime.now(dt.timezone.utc) + return { + "schema_version": SCHEMA_VERSION, + "runtime": runtime, + "generated_at_utc": _iso(moment), + "home": str(home), + "home_source": home_source, + "project": project, + "project_source": project_source if project is not None else None, + "content_injected": False, + "files": files, + "excluded": excluded, + "bytes_total": sum(int(entry["bytes"]) for entry in files), + "sync": sync_info, + "pull": pull_info, + "warnings": warnings, + "result": RESULT_DEGRADED if warnings else RESULT_OK, + } + + +def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]: + entry: Dict[str, Any] = { + "tier": tier, + "name": name, + "path": str(path), + "relative": path.relative_to(home).as_posix(), + "state": MISSING, + "bytes": 0, + "modified_at_utc": None, + } + try: + info = os.stat(path) + except FileNotFoundError: + return entry + except OSError as exc: + entry["state"] = UNREADABLE + entry["error"] = exc.strerror or str(exc) + return entry + if not stat.S_ISREG(info.st_mode): + entry["state"] = UNREADABLE + entry["error"] = "not a regular file" + return entry + try: + with open(path, "rb") as handle: + handle.read(1) + except OSError as exc: + entry["state"] = UNREADABLE + entry["error"] = exc.strerror or str(exc) + return entry + entry["state"] = READABLE + entry["bytes"] = info.st_size + entry["modified_at_utc"] = _iso(dt.datetime.fromtimestamp(info.st_mtime, dt.timezone.utc)) + return entry + + +def _sync( + home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str] +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + marker = sync.is_configured(home) + info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None} + pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []} + if pull: + try: + outcome = sync.pull(home, runner=runner) + pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)} + except (sync.SyncError, OSError) as exc: + pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]} + if not pull_info["ok"]: + said = pull_info["lines"][0] if pull_info["lines"] else pull_info["status"] + warnings.append(f"memory pull did not complete; local memory may be stale: {said}") + if marker and sync.is_git_repo(home, runner): + result = (runner or run_git)(["log", "-1", "--format=%ct"], cwd=home, timeout=None) + stamp = result.stdout.strip() + if result.ok and stamp.isdigit(): + info["last_commit_at_utc"] = _iso(dt.datetime.fromtimestamp(int(stamp), dt.timezone.utc)) + return info, pull_info + + +# --- rendering -------------------------------------------------------------- + + +def render_text(manifest: Dict[str, Any], *, max_chars: int = DEFAULT_MAX_CHARS) -> str: + """The manifest as the lines a session-start hook prints; cut at ``max_chars`` with a notice.""" + head = f"agent-memory startup ({manifest['runtime']}): home {manifest['home']} ({manifest['home_source']})" + if manifest["project"] is not None: + head += f", project {manifest['project']} ({manifest['project_source'] or 'flag'})" + else: + head += ", no project" + lines = [head] + pull = manifest["pull"] + if pull["requested"]: + if pull["lines"]: + lines.extend(pull["lines"]) + elif pull["status"] == "not_configured": + lines.append("memory pull: sync is not enabled for this home; skipped.") + if manifest["sync"]["last_commit_at_utc"]: + lines.append(f"memory sync: last commit {manifest['sync']['last_commit_at_utc']}.") + + number = 0 + project_files = [entry for entry in manifest["files"] if entry["tier"] == layout.PROJECT.name] + org_files = [entry for entry in manifest["files"] if entry["tier"] == layout.ORG.name] + if project_files: + lines.append("Project memory, read in this order (states are readability only; no content is injected):") + for entry in project_files: + number += 1 + lines.append(f" {number}. {_describe(entry)}") + lines.append("Org memory, curated context; consult what governs the work before doing it (not read by default):") + for entry in org_files: + number += 1 + lines.append(f" {number}. {_describe(entry)}") + lines.append(f"Excluded by default: {', '.join(manifest['excluded'])}") + if manifest["warnings"]: + lines.append("Warnings:") + lines.extend(f" - {warning}" for warning in manifest["warnings"]) + return _bound("\n".join(lines) + "\n", max_chars, manifest["runtime"]) + + +def render_codex_hook(manifest: Dict[str, Any], *, max_chars: int = DEFAULT_MAX_CHARS) -> Dict[str, Any]: + """The Codex ``SessionStart`` hook envelope carrying the rendered text as ``additionalContext``.""" + output: Dict[str, Any] = { + "continue": True, + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": render_text(manifest, max_chars=max_chars), + }, + } + if manifest["result"] != RESULT_OK: + output["systemMessage"] = "agent-memory startup completed in degraded mode; see the warnings in its context." + return output + + +def _describe(entry: Dict[str, Any]) -> str: + if entry["state"] == READABLE: + return f"{entry['relative']}: readable, {entry['bytes']} bytes, modified {entry['modified_at_utc']}" + reason = f" ({entry['error']})" if entry.get("error") else "" + return f"{entry['relative']}: {entry['state']}{reason}" + + +def _bound(text: str, max_chars: int, runtime: str) -> str: + """``text`` cut to at most ``max_chars`` characters, notice included; a budget below zero counts as zero.""" + max_chars = max(0, max_chars) + if len(text) <= max_chars: + return text + suffix = ( + f"\n[agent-memory: manifest text cut at {max_chars} characters; " + f"run `agent-memory startup --runtime {runtime} --json` for the whole manifest]\n" + ) + if len(suffix) >= max_chars: + suffix = "[cut]\n"[:max_chars] + return text[: max_chars - len(suffix)] + suffix + + +def _iso(moment: dt.datetime) -> str: + return moment.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/agent_memory/status.py b/src/agent_memory/status.py new file mode 100644 index 0000000..28989f9 --- /dev/null +++ b/src/agent_memory/status.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory status``: which home resolves, and where its sync stands. + +The readout is the resolution (path, the rule that chose it, the bound +project), the layout (marker, allowlist, tiers), and, when the home is a sync +repository, its :class:`~agent_memory.sync.GitState`. The remote is contacted +only with ``--fetch``; otherwise ahead/behind count against the last fetched +upstream. Exit 0 when the tree is clean and not diverged, 1 when it is dirty +or diverged (or the home does not exist). Ahead and behind are reported, not +failed: they are what ``push`` and ``pull`` are for. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +from . import layout, sync +from .doctor import enclosing_repository, sync_state_text +from .git_runner import GitRunner +from .home import HomeResolution +from .sync import GitState + +EXIT_OK = 0 +EXIT_FAILED = 1 + + +@dataclass(frozen=True) +class Status: + """Everything the verb prints, plus the exit contract.""" + + home: Path + source: str + project: Optional[str] + exists: bool + marker: bool = False + gitignore: str = "" + org_memory: bool = False + projects: int = 0 + #: ``None`` without the marker; otherwise whether the home is a git repository. + repository: Optional[bool] = None + #: Set when the home is a repository only by sitting inside another one. + enclosing: Optional[Path] = None + git: Optional[GitState] = None + #: Whether the remote was contacted for this readout. + fetched: bool = False + + @property + def clean(self) -> bool: + """The home exists and its tree is neither dirty nor diverged.""" + if not self.exists: + return False + return self.git is None or not (self.git.dirty or self.git.diverged) + + @property + def exit_code(self) -> int: + return EXIT_OK if self.clean else EXIT_FAILED + + def lines(self) -> List[str]: + lines = [f"home: {self.home}", f"source: {self.source}"] + if self.project: + lines.append(f"project: {self.project}") + if not self.exists: + lines.append("exists: no") + return lines + lines.extend( + [ + "exists: yes", + f"marker: {'present' if self.marker else 'absent'}", + f"gitignore: {self.gitignore}", + f"org-memory: {'present' if self.org_memory else 'absent'}", + f"projects: {self.projects} with a memory dir", + ] + ) + if self.repository is None: + lines.append("sync: not configured") + elif not self.repository: + lines.append("sync: marker present, but the home is not a git repository") + elif self.enclosing is not None: + lines.append(f"sync: marker present, but the home is inside the repository at {self.enclosing}, not one of its own") + elif self.git is not None: + lines.append(f"sync: {sync_state_text(self.git)}") + if self.git.has_remote: + lines.append("fetch: done" if self.fetched else "fetch: skipped (pass --fetch to contact the remote)") + lines.append(f"tree: {'dirty' if self.git.dirty else 'clean'}") + return lines + + +def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional[GitRunner] = None) -> Status: + """Read the home ``resolution`` names; nothing is changed, and no network without ``fetch``.""" + home = resolution.path + if not home.is_dir(): + return Status(home, resolution.source, resolution.project, exists=False) + tiers = layout.allowed_memory_dirs(home) + org = layout.org_memory_dir(home) + marker = sync.is_configured(home) + repository: Optional[bool] = None + enclosing: Optional[Path] = None + git: Optional[GitState] = None + if marker: + repository = sync.is_git_repo(home, runner) + if repository: + enclosing = enclosing_repository(home, runner) + if repository and enclosing is None: + git = sync.git_state(home, runner=runner, fetch=fetch) + return Status( + home, + resolution.source, + resolution.project, + exists=True, + marker=marker, + gitignore=gitignore_state(home), + org_memory=org in tiers, + projects=len([path for path in tiers if path != org]), + repository=repository, + enclosing=enclosing, + git=git, + fetched=fetch and git is not None and git.has_remote, + ) + + +def gitignore_state(home: Path) -> str: + """How the root ``.gitignore`` relates to the canonical allowlist, in a few words.""" + path = home / layout.GITIGNORE_FILE + try: + data = path.read_bytes() + except FileNotFoundError: + return "absent" + except OSError as exc: + return f"unreadable ({exc.strerror})" + if data == layout.gitignore_text().encode("utf-8"): + return "canonical" + if sync.gitignore_has_managed_block(data.decode("utf-8", errors="replace")): + return "canonical managed block, other lines kept" + return "present, differs from canonical" diff --git a/src/agent_memory/sync.py b/src/agent_memory/sync.py new file mode 100644 index 0000000..0ca006c --- /dev/null +++ b/src/agent_memory/sync.py @@ -0,0 +1,649 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Sync a memory home through plain git. + +The engine owns the sync marker, the managed block of the home's +``.gitignore``, the git state readout and the verbs ``init``, ``clone``, +``pull``, ``push`` and ``disable``. It never reads memory content, never +merges and never touches ``keys/``. + +The publication boundary +------------------------ +A memory commit holds the selected paths and nothing else. The selection is +``git status`` limited to the layout's sync allowlist, and every candidate +must pass the layout's path predicate, which denies a never-synced name such +as ``keys/`` at any depth whatever the ignore file says; if anything selected +fails it the publish is refused. Otherwise exactly that selection is staged +and committed as a partial commit, every name taken literally (a project +called ``a*`` is a name, not a pattern), so whatever else the index holds -- +a runtime file somebody staged by hand -- stays staged, uncommitted and +reported. The home must be the root of its own git worktree; a home nested +inside another repository is refused before anything is written. + +``.gitignore`` is never overwritten. The canonical allowlist is a managed +block that ``init`` puts at the head of the file when it is missing and +leaves alone when it is present, keeping every other line, and every write +comes back with a before/after receipt. + +The network verbs (fetch, pull, push, clone) run under a 30 s timeout. +``pull`` fast-forwards only when the tree is clean, not ahead, not diverged +and an upstream exists. ``push`` refuses a repository that is behind or +diverged, and a push the remote rejects leaves the local commit in place and +says so: a local-only commit and remote delivery are distinct outcomes. +""" + +from __future__ import annotations + +import datetime as dt +import os +import shutil +import socket +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Mapping, Optional, Sequence, Tuple + +from . import layout +from .git_runner import GitResult, GitRunner, run_git + +MARKER_FILE = layout.MARKER_FILE +GITIGNORE_FILE = layout.GITIGNORE_FILE +MARKER_TEXT = "agent-memory sync repository. Remove this file to disable syncing locally.\n" +NETWORK_TIMEOUT_SECONDS = 30 +DEFAULT_REMOTE = "origin" + +#: The agent name a commit is published under, when the caller does not pass one. +ENV_AGENT = "AGENT_MEMORY_AGENT" +_AGENT_FALLBACK_ENV = ("AGENT_NAME", "USER") +UNKNOWN_AGENT = "unknown" + + +class SyncError(Exception): + """A precondition failed; nothing was changed.""" + + +@dataclass(frozen=True) +class GitState: + """Where the repository stands against its upstream, after a fetch.""" + + has_remote: bool + has_upstream: bool + upstream: str = "" + ahead: int = 0 + behind: int = 0 + dirty: bool = False + fetch_failed: bool = False + fetch_timed_out: bool = False + fetch_output: str = "" + + @property + def diverged(self) -> bool: + return self.ahead > 0 and self.behind > 0 + + +@dataclass(frozen=True) +class GitignoreReceipt: + """What :func:`ensure_gitignore` found and what it left behind.""" + + path: Path + #: ``created``, ``unchanged`` or ``updated``. + action: str + before: Optional[str] + after: str + + @property + def changed(self) -> bool: + return self.before != self.after + + def lines(self) -> List[str]: + if not self.changed: + return [f"{GITIGNORE_FILE}: managed block present; left unchanged."] + if self.before is None: + return [f"{GITIGNORE_FILE}: created with the managed block."] + kept = self.after[len(layout.gitignore_text()) :] + out = [ + f"{GITIGNORE_FILE}: managed block added at the top; " + f"{len(kept.splitlines())} existing line(s) kept after it.", + f"{GITIGNORE_FILE} before:", + *_indent(self.before.splitlines() or ["(empty)"]), + f"{GITIGNORE_FILE} after:", + *_indent(self.after.splitlines()), + ] + return out + + +@dataclass(frozen=True) +class Outcome: + """The result of a verb: a distinct status, whether it counts as success, and what to say.""" + + status: str + ok: bool + lines: Tuple[str, ...] = () + #: Paths the verb committed, home-relative. + committed: Tuple[str, ...] = () + #: Paths that were staged outside the selection and were left exactly as found. + preserved: Tuple[str, ...] = () + receipt: Optional[GitignoreReceipt] = None + + +# --- marker and ignore file ------------------------------------------------- + + +def marker_path(home: Path) -> Path: + return home / MARKER_FILE + + +def is_configured(home: Path) -> bool: + return marker_path(home).is_file() + + +def write_marker(home: Path) -> bool: + """Create the marker if it is missing; an existing marker keeps its bytes.""" + path = marker_path(home) + if path.is_file(): + return False + path.write_text(MARKER_TEXT, encoding="utf-8") + return True + + +def ensure_gitignore(home: Path) -> GitignoreReceipt: + """Put the managed block at the head of ``.gitignore`` unless it is already there. + + The block is the canonical allowlist from :func:`layout.gitignore_text`. + A missing file is created with the block alone, so a fresh home's file is + byte-identical to the canonical text. A file that already contains the + block, contiguous and on line boundaries, is left untouched wherever the + block sits. Otherwise the block goes first and every line of the existing + file that is not itself a block line follows it verbatim: custom rules + survive, and a file carrying an older version of the block is brought up + to date without duplicating its lines. + """ + path = home / GITIGNORE_FILE + block = layout.gitignore_text() + try: + before: Optional[str] = path.read_text(encoding="utf-8") + except FileNotFoundError: + before = None + if before is None: + after, action = block, "created" + elif _contains_block(before, block): + after, action = before, "unchanged" + else: + managed = set(block.splitlines()) + kept = [line for line in before.replace("\r\n", "\n").splitlines() if line not in managed] + after = block + ("\n".join(kept) + "\n" if kept else "") + action = "updated" + if after != before: + path.write_text(after, encoding="utf-8") + return GitignoreReceipt(path, action, before, after) + + +def _contains_block(text: str, block: str) -> bool: + normalized = text.replace("\r\n", "\n") + return normalized.startswith(block) or f"\n{block}" in normalized + + +def gitignore_has_managed_block(text: str) -> bool: + """Whether ``text`` carries the managed allowlist block, contiguous and on line boundaries.""" + return _contains_block(text, layout.gitignore_text()) + + +# --- git readout ------------------------------------------------------------ + + +def is_git_repo(home: Path, runner: Optional[GitRunner] = None) -> bool: + return _git(home, ["rev-parse", "--is-inside-work-tree"], runner).ok + + +def worktree_root(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]: + """The root of the worktree ``home`` sits in, or ``None`` outside any repository.""" + result = _git(home, ["rev-parse", "--show-toplevel"], runner) + if not result.ok or not result.stdout.strip(): + return None + return Path(result.stdout.strip()) + + +def git_state(home: Path, *, runner: Optional[GitRunner] = None, fetch: bool = True) -> GitState: + dirty = bool(_status_porcelain(home, runner)) + remote = _has_remote(home, runner) + fetch_failed = fetch_timed_out = False + fetch_output = "" + if fetch and remote: + fetched = _git(home, ["fetch", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS) + fetch_failed = not fetched.ok + fetch_timed_out = fetched.timed_out + fetch_output = fetched.output + upstream = _upstream(home, runner) + ahead = behind = 0 + if upstream: + counted = _git(home, ["rev-list", "--left-right", "--count", f"HEAD...{upstream}"], runner) + parts = counted.stdout.split() + if counted.ok and len(parts) >= 2: + ahead, behind = int(parts[0]), int(parts[1]) + else: + fetch_failed, fetch_output = True, counted.output + return GitState( + has_remote=remote, + has_upstream=bool(upstream), + upstream=upstream, + ahead=ahead, + behind=behind, + dirty=dirty, + fetch_failed=fetch_failed, + fetch_timed_out=fetch_timed_out, + fetch_output=fetch_output, + ) + + +# --- the verbs -------------------------------------------------------------- + + +def init( + home: Path, + *, + remote: Optional[str] = None, + agent: Optional[str] = None, + runner: Optional[GitRunner] = None, + env: Optional[Mapping[str, str]] = None, +) -> Outcome: + """Make ``home`` a memory repository: git, the managed ignore block, the marker, one commit. + + Rerunning on an initialized home changes nothing it does not have to: + the ignore block is only added when missing, the marker keeps its bytes, + and the commit covers only what the allowlist selects. + """ + home = _home(home) + home.mkdir(parents=True, exist_ok=True) + if is_git_repo(home, runner): + _require_root(home, runner) + else: + result = _git(home, ["init", "--quiet"], runner) + if not result.ok: + raise SyncError(f"git init failed: {result.output}") + receipt = ensure_gitignore(home) + lines = receipt.lines() + if write_marker(home): + lines.append(f"{MARKER_FILE}: created.") + if remote: + verb = "set-url" if _git(home, ["remote", "get-url", DEFAULT_REMOTE], runner).ok else "add" + result = _git(home, ["remote", verb, DEFAULT_REMOTE, remote], runner) + if not result.ok: + raise SyncError(f"git remote {verb} failed: {result.output}") + lines.append(f"remote {DEFAULT_REMOTE}: {remote}") + + published = _publish(home, agent=agent, runner=runner, env=env) + lines.extend(published.lines) + if not published.ok: + return _with(published, lines=lines, receipt=receipt) + if not remote: + return Outcome("local_only", True, tuple(lines), published.committed, published.preserved, receipt) + return _deliver(home, published, git_state(home, runner=runner, fetch=False), lines, runner, receipt) + + +def clone(home: Path, url: str, *, force: bool = False, runner: Optional[GitRunner] = None) -> Outcome: + """Clone a memory repository into ``home``; a non-empty ``home`` is refused unless ``force``. + + With ``force`` the existing directory is moved aside to a timestamped + sibling first and moved back if the clone fails. + """ + home = _home(home) + if not _is_non_empty(home): + home.parent.mkdir(parents=True, exist_ok=True) + result = _git(home.parent, ["clone", url, str(home)], runner, timeout=NETWORK_TIMEOUT_SECONDS) + if not result.ok: + raise SyncError(f"git clone failed: {result.output}") + return Outcome("cloned", True, (f"Cloned the memory repository into {home}.",)) + if not force: + raise SyncError(f"refusing to clone into a non-empty home: {home}; pass --force to move it aside first") + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d%H%M%S") + backup = home.with_name(f"{home.name}.backup-{stamp}") + shutil.move(str(home), str(backup)) + try: + result = _git(home.parent, ["clone", url, str(home)], runner, timeout=NETWORK_TIMEOUT_SECONDS) + except Exception: + if not home.exists(): + shutil.move(str(backup), str(home)) + raise + if not result.ok: + if home.exists(): + shutil.rmtree(home) + shutil.move(str(backup), str(home)) + raise SyncError(f"git clone failed: {result.output}") + return Outcome( + "cloned", + True, + (f"Moved the existing home aside to {backup}.", f"Cloned the memory repository into {home}."), + ) + + +def pull(home: Path, *, runner: Optional[GitRunner] = None) -> Outcome: + """Fast-forward ``home`` from its upstream when that is the only thing that could happen. + + Silent on a home that is not configured for sync. Every state that rules + a fast-forward out (a failed fetch, a dirty tree, divergence) is its own + status and not a success; being ahead or having no upstream is reported + and is not an error. + """ + home = _home(home) + if not is_configured(home): + return Outcome("not_configured", True) + _require_repo(home, runner) + state = git_state(home, runner=runner, fetch=True) + if not state.has_remote: + return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",)) + if state.fetch_failed: + return _fetch_failure(state, "memory pull") + if state.dirty: + return Outcome("dirty", False, ("memory pull: uncommitted changes present; not pulling.",)) + if state.diverged: + return Outcome("diverged", False, ("memory pull: diverged from upstream; resolve manually.",)) + if not state.has_upstream: + return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",)) + if state.ahead: + return Outcome("ahead", True, (f"memory pull: {state.ahead} unpushed commit(s); nothing to pull.",)) + if not state.behind: + return Outcome("up_to_date", True, ("memory pull: already synced.",)) + result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS) + if result.timed_out: + return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",)) + if not result.ok: + return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",)) + return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",)) + + +def push( + home: Path, + *, + agent: Optional[str] = None, + runner: Optional[GitRunner] = None, + env: Optional[Mapping[str, str]] = None, +) -> Outcome: + """Commit the allowlisted memory changes and deliver them when a remote exists. + + Silent on a home that is not configured for sync. A home that is behind + or diverged is refused before anything is committed. A commit that could + not be delivered -- no remote, an unreachable one, a rejected push -- is + reported as such, distinctly from delivery. + """ + home = _home(home) + if not is_configured(home): + return Outcome("not_configured", True) + _require_repo(home, runner) + state = git_state(home, runner=runner, fetch=True) + if state.has_remote and not state.fetch_failed: + if state.diverged: + return Outcome("diverged", False, ("memory push: diverged from upstream; resolve manually before pushing.",)) + if state.behind: + return Outcome( + "behind", False, (f"memory push: behind upstream by {state.behind} commit(s); pull before pushing.",) + ) + published = _publish(home, agent=agent, runner=runner, env=env) + if not published.ok: + return published + lines = list(published.lines) + if not state.has_remote: + lines.append("memory push: no remote configured; the commit remains local.") + return Outcome("local_only", True, tuple(lines), published.committed, published.preserved) + return _deliver(home, published, state, lines, runner) + + +def disable(home: Path) -> Outcome: + """Remove the marker; the repository and its history stay in place.""" + home = _home(home) + path = marker_path(home) + if path.exists(): + path.unlink() + return Outcome("disabled", True, (f"Removed {path}; syncing is disabled for this home.",)) + return Outcome("already_disabled", True, ("Syncing is already disabled for this home.",)) + + +# --- the publication boundary ----------------------------------------------- + + +def select_paths(home: Path, runner: Optional[GitRunner] = None) -> Tuple[List[str], List[str]]: + """Split what ``git status`` reports inside the allowlisted tiers into (selected, outside). + + The query is limited to the layout's tier patterns plus the ignore file + and the marker, so nothing elsewhere in the tree is ever a candidate; and + every candidate is checked against :func:`layout.is_allowed_memory_path`, + so a widened ignore file cannot smuggle a tier's unsynced subdirectory in. + """ + pathspecs = [GITIGNORE_FILE, MARKER_FILE, *(f":(glob){tier.pattern}/**" for tier in layout.TIERS)] + result = _git(home, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", *pathspecs], runner) + if not result.ok: + raise SyncError(f"git status failed: {result.output}") + paths = _parse_status_z(result.stdout) + selected = sorted(path for path in paths if layout.is_allowed_memory_path(path)) + outside = sorted(path for path in paths if not layout.is_allowed_memory_path(path)) + return selected, outside + + +def staged_paths(home: Path, runner: Optional[GitRunner] = None) -> List[str]: + """Every path the index differs in from HEAD (or from the empty tree before the first commit).""" + result = _git(home, ["diff", "--cached", "--name-only", "-z"], runner) + if not result.ok: + raise SyncError(f"git diff --cached failed: {result.output}") + return [path for path in result.stdout.split("\0") if path] + + +def committed_paths(home: Path, runner: Optional[GitRunner] = None) -> List[str]: + """The paths HEAD's commit touched.""" + result = _git(home, ["show", "--format=", "--name-only", "-z", "HEAD"], runner) + if not result.ok: + raise SyncError(f"git show failed: {result.output}") + return [path for path in result.stdout.split("\0") if path] + + +def resolve_agent(explicit: Optional[str] = None, env: Optional[Mapping[str, str]] = None) -> str: + """The name a commit is published under: the caller's, else the environment's, else ``unknown``.""" + if explicit: + return explicit + source: Mapping[str, str] = os.environ if env is None else env + for name in (ENV_AGENT, *_AGENT_FALLBACK_ENV): + value = source.get(name) + if value: + return value + return UNKNOWN_AGENT + + +def commit_message(file_count: int, agent: str, *, now: Optional[dt.datetime] = None) -> str: + host = socket.gethostname().split(".")[0] or "host" + today = (now or dt.datetime.now(dt.timezone.utc)).strftime("%Y-%m-%d") + return f"memory: {agent}@{host} {today} ({file_count} files)" + + +def _publish( + home: Path, + *, + agent: Optional[str], + runner: Optional[GitRunner], + env: Optional[Mapping[str, str]], +) -> Outcome: + selected, outside = select_paths(home, runner) + if outside: + return Outcome( + "outside_allowlist", + False, + ( + f"memory publish: refusing to commit; {len(outside)} path(s) inside a memory tier fall outside " + f"the sync allowlist (a never-synced name such as keys/, or a widened {GITIGNORE_FILE}): " + f"{', '.join(outside)}", + ), + ) + preserved = tuple(sorted(set(staged_paths(home, runner)) - set(selected))) + lines: List[str] = [] + if preserved: + lines.append( + f"memory publish: {len(preserved)} staged path(s) outside the allowlist left staged and " + f"uncommitted: {', '.join(preserved)}" + ) + if not selected: + lines.append("memory publish: no memory changes to commit.") + return Outcome("nothing_to_commit", True, tuple(lines), (), preserved) + + message = commit_message(len(selected), resolve_agent(agent, env)) + with _pathspec_file(selected) as pathspec: + added = _git(home, ["--literal-pathspecs", "add", *_from_file(pathspec)], runner) + if not added.ok: + return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}")) + committed = _git(home, ["--literal-pathspecs", "commit", "--quiet", "-m", message, *_from_file(pathspec)], runner) + if not committed.ok: + return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}")) + published = committed_paths(home, runner) + stray = sorted(path for path in published if not layout.is_allowed_memory_path(path)) + if stray: + raise SyncError(f"memory publish: HEAD commits paths outside the allowlist: {', '.join(stray)}") + lines.append(f"memory publish: committed {len(published)} file(s).") + return Outcome("committed", True, tuple(lines), tuple(published), preserved) + + +def _deliver( + home: Path, + published: Outcome, + state: GitState, + lines: List[str], + runner: Optional[GitRunner], + receipt: Optional[GitignoreReceipt] = None, +) -> Outcome: + if state.fetch_failed: + failure = _fetch_failure(state, "memory push") + lines.append(f"{failure.lines[0]} The commit remains local.") + return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt) + if not published.committed and state.has_upstream and not state.ahead: + lines.append("memory push: remote already up to date.") + return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt) + result = _push_remote(home, state, runner) + if not result.ok: + what = "timed out" if result.timed_out else "was rejected" + lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip()) + status = "push_timed_out" if result.timed_out else "push_failed" + return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt) + lines.append("memory push: delivered to the remote.") + return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt) + + +def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> GitResult: + if state.has_upstream: + return _git(home, ["push", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS) + remote = _default_remote(home, runner) + branch = _current_branch(home, runner) + return _git(home, ["push", "--quiet", "-u", remote, branch], runner, timeout=NETWORK_TIMEOUT_SECONDS) + + +def _fetch_failure(state: GitState, verb: str) -> Outcome: + if state.fetch_timed_out: + return Outcome("fetch_timed_out", False, (f"{verb}: the fetch timed out; {state.fetch_output}".rstrip(),)) + return Outcome("fetch_failed", False, (f"{verb}: the fetch failed; {state.fetch_output}".rstrip(),)) + + +# --- helpers ---------------------------------------------------------------- + + +def _git( + cwd: Path, args: Sequence[str], runner: Optional[GitRunner], *, timeout: Optional[float] = None +) -> GitResult: + return (runner or run_git)(args, cwd=cwd, timeout=timeout) + + +def _home(value: Path) -> Path: + return Path(value).expanduser().absolute() + + +def _require_repo(home: Path, runner: Optional[GitRunner]) -> None: + if not is_git_repo(home, runner): + raise SyncError(f"{MARKER_FILE} is present, but {home} is not a git repository") + _require_root(home, runner) + + +def _require_root(home: Path, runner: Optional[GitRunner]) -> None: + root = worktree_root(home, runner) + if root is None: + raise SyncError(f"{home} is not inside a git worktree") + if root.resolve() != home.resolve(): + raise SyncError( + f"root mismatch: {home} is inside the git worktree {root}; " + "a memory home must be the root of its own repository" + ) + + +def _status_porcelain(home: Path, runner: Optional[GitRunner]) -> str: + result = _git(home, ["status", "--porcelain"], runner) + if not result.ok: + raise SyncError(f"git status failed: {result.output}") + return result.stdout.strip() + + +def _has_remote(home: Path, runner: Optional[GitRunner]) -> bool: + result = _git(home, ["remote"], runner) + return result.ok and bool(result.stdout.strip()) + + +def _default_remote(home: Path, runner: Optional[GitRunner]) -> str: + result = _git(home, ["remote"], runner) + remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] if result.ok else [] + return remotes[0] if remotes else DEFAULT_REMOTE + + +def _upstream(home: Path, runner: Optional[GitRunner]) -> str: + result = _git(home, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], runner) + return result.stdout.strip() if result.ok else "" + + +def _current_branch(home: Path, runner: Optional[GitRunner]) -> str: + result = _git(home, ["branch", "--show-current"], runner) + return result.stdout.strip() if result.ok and result.stdout.strip() else "HEAD" + + +def _is_non_empty(path: Path) -> bool: + return path.exists() and any(path.iterdir()) + + +def _parse_status_z(data: str) -> List[str]: + """Paths from ``git status --porcelain=v1 -z``; a rename or copy contributes both of its paths.""" + paths: List[str] = [] + fields = data.split("\0") + index = 0 + while index < len(fields): + entry = fields[index] + index += 1 + if len(entry) < 4: + continue + paths.append(entry[3:]) + if entry[0] in "RC" and index < len(fields) and fields[index]: + paths.append(fields[index]) + index += 1 + return paths + + +def _from_file(pathspec: Path) -> List[str]: + return [f"--pathspec-from-file={pathspec}", "--pathspec-file-nul"] + + +class _pathspec_file: + """A NUL-separated pathspec file for ``--pathspec-from-file``, removed on exit.""" + + def __init__(self, paths: Iterable[str]) -> None: + self._paths = list(paths) + self._path: Optional[Path] = None + + def __enter__(self) -> Path: + handle = tempfile.NamedTemporaryFile("wb", prefix="agent-memory-pathspec-", delete=False) + with handle: + handle.write("\0".join(self._paths).encode("utf-8", "surrogateescape")) + self._path = Path(handle.name) + return self._path + + def __exit__(self, *_: object) -> None: + if self._path is not None: + try: + self._path.unlink() + except OSError: + pass + + +def _with(outcome: Outcome, *, lines: Sequence[str], receipt: Optional[GitignoreReceipt]) -> Outcome: + return Outcome(outcome.status, outcome.ok, tuple(lines), outcome.committed, outcome.preserved, receipt) + + +def _indent(lines: Iterable[str]) -> List[str]: + return [f" {line}" for line in lines] diff --git a/src/agent_memory/templates/org-memory/decisions.md b/src/agent_memory/templates/org-memory/decisions.md new file mode 100644 index 0000000..8057b7b --- /dev/null +++ b/src/agent_memory/templates/org-memory/decisions.md @@ -0,0 +1,5 @@ +# Org Decisions + + + + diff --git a/src/agent_memory/templates/org-memory/recent.md b/src/agent_memory/templates/org-memory/recent.md new file mode 100644 index 0000000..16d977d --- /dev/null +++ b/src/agent_memory/templates/org-memory/recent.md @@ -0,0 +1,16 @@ +# Org Memory — Rolling Summary + + + + +## Current State + + + +## Active Decisions + + + +## Standing Rules + + diff --git a/src/agent_memory/templates/org-memory/rules.md b/src/agent_memory/templates/org-memory/rules.md new file mode 100644 index 0000000..71db6d3 --- /dev/null +++ b/src/agent_memory/templates/org-memory/rules.md @@ -0,0 +1,5 @@ +# Org Rules + + + + diff --git a/src/agent_memory/templates/project-memory/decision_log.md b/src/agent_memory/templates/project-memory/decision_log.md new file mode 100644 index 0000000..0923be2 --- /dev/null +++ b/src/agent_memory/templates/project-memory/decision_log.md @@ -0,0 +1,3 @@ +# Decision Log + + diff --git a/src/agent_memory/templates/project-memory/known_debt.md b/src/agent_memory/templates/project-memory/known_debt.md new file mode 100644 index 0000000..e945671 --- /dev/null +++ b/src/agent_memory/templates/project-memory/known_debt.md @@ -0,0 +1,8 @@ +# Known Debt + +Use this file to track verified, unresolved project debt that future sessions +should not rediscover from scratch. + +| Item | Severity | Date Found | Source | Status | +| --- | --- | --- | --- | --- | +| _None yet._ | | | | | diff --git a/src/agent_memory/templates/project-memory/open_threads.md b/src/agent_memory/templates/project-memory/open_threads.md new file mode 100644 index 0000000..1e41ddf --- /dev/null +++ b/src/agent_memory/templates/project-memory/open_threads.md @@ -0,0 +1,3 @@ +# Open Threads + +- None yet. diff --git a/src/agent_memory/templates/project-memory/project_facts.md b/src/agent_memory/templates/project-memory/project_facts.md new file mode 100644 index 0000000..eac192a --- /dev/null +++ b/src/agent_memory/templates/project-memory/project_facts.md @@ -0,0 +1,4 @@ +# Project Facts + + + diff --git a/src/agent_memory/templates/workflow/SKILL.md b/src/agent_memory/templates/workflow/SKILL.md new file mode 100644 index 0000000..e98df2f --- /dev/null +++ b/src/agent_memory/templates/workflow/SKILL.md @@ -0,0 +1,42 @@ +--- +name: agent-memory +description: Cross-session memory for this repository. Use at session start to read the bounded memory context (recall), and whenever a decision is made that a later session must not rediscover (capture). Also use when asked what was decided before. +--- + +# agent-memory workflow for {runtime} + +Managed by `agent-memory setup {runtime}`: a rerun regenerates this file only +while its digest matches a shipped template; an edited file is reported as a +conflict and kept. The rules are the same for every runtime; only the agent +name differs. + +## Recall, at session start + +The session-start hook printed the startup manifest: the memory files to read, +in order, with their readability only. No content was injected. Before acting +on project work, read the bounded context in one step: + + agent-memory recall + +It prints the project's active files (`project_facts.md`, `decision_log.md`, +`open_threads.md`, `known_debt.md`), then the curated org files (`recent.md`, +`decisions.md`, `rules.md`), cut at a character budget (`--max-chars`, default +8000). Cite a decision by its date heading and its text. `archive/`, `events/` +and `debriefs/` are never loaded; read them only when the work needs them. + +## Capture, when a decision is made + +When the user decides something that a future session must not rediscover, +record it once, in one sentence, with the reason: + + agent-memory capture --agent {runtime} "" --why "" --source "" + +It appends a dated entry, newest first, to the project's `decision_log.md`, +with provenance: the agent, the UTC time and the source. Capture only what the +user decided, not what you propose; confirm first when in doubt. One decision +per entry. Stable facts go to `project_facts.md` by hand, not through capture. + +## Out of scope + +No synthesis, search or index; no writes to the org tier; no push. Syncing the +home is `agent-memory push`, run deliberately, never from this workflow. diff --git a/src/agent_memory/workflow.py b/src/agent_memory/workflow.py new file mode 100644 index 0000000..dedb0d3 --- /dev/null +++ b/src/agent_memory/workflow.py @@ -0,0 +1,287 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""The minimal memory workflow: ``agent-memory capture`` and ``agent-memory recall``. + +Two verbs and one authored text. ``capture`` appends one decision, with its +provenance, to the project's ``decision_log.md``, newest first under a UTC +date heading; it never writes through a symlink (a linked component anywhere +below the home, the file included, is refused, never followed), replaces the +file atomically with the file's own mode, and touches no other file. ``recall`` prints the content of the files the +startup manifest lists, in the manifest's order, cut at a character budget +with a notice: the bounded read the manifest describes but never performs. +``archive/``, ``events/`` and ``debriefs/`` are never loaded. + +The authored text is the workflow file ``setup `` installs beside the +hook, rendered from one shipped template with the runtime's name; every +runtime's wrapper says the same thing. +""" + +from __future__ import annotations + +import datetime as dt +import os +import re +import secrets +import stat as stat_mod +from importlib import resources +from pathlib import Path +from typing import Any, Dict, List, Optional + +from . import layout, startup + +SCHEMA_VERSION = 1 +DECISION_FILE = "decision_log.md" +DEFAULT_AGENT_ENV = "AGENT_MEMORY_AGENT" +WORKFLOW_TEMPLATE = "templates/workflow/SKILL.md" + +_HEADING = re.compile(r"^## (\d{4}-\d{2}-\d{2})\s*$") +_SPACE = re.compile(r"\s+") + + +class WorkflowError(Exception): + """A precondition failed; nothing was written.""" + + +# --- the authored text --------------------------------------------------------- + + +def workflow_text(runtime: str) -> str: + """The workflow file for ``runtime``, byte for byte what setup writes.""" + template = (resources.files(__package__) / WORKFLOW_TEMPLATE).read_text(encoding="utf-8") + return template.replace("{runtime}", runtime) + + +# --- capture ------------------------------------------------------------------- + + +def default_agent() -> str: + """Who is capturing: ``$AGENT_MEMORY_AGENT`` (the hook exports it), else the user, else ``unknown``.""" + return os.environ.get(DEFAULT_AGENT_ENV) or os.environ.get("USER") or "unknown" + + +def default_runtime() -> str: + """The runtime reading: ``$AGENT_MEMORY_AGENT`` when it names one, else claude.""" + agent = os.environ.get(DEFAULT_AGENT_ENV) + return agent if agent in startup.RUNTIMES else startup.RUNTIME_CLAUDE + + +def format_entry(decision: str, *, why: Optional[str], agent: str, source: Optional[str], now: dt.datetime) -> str: + """One decision as a list line: the decision, its reason, then the provenance in parentheses.""" + text = _one_line(decision) + if not text: + raise WorkflowError("the decision is empty") + parts = [f"- **{text}**"] + reason = _one_line(why or "") + if reason: + parts.append(f" Why: {reason}") + provenance = [_one_line(agent) or "unknown", _iso(now)] + reference = _one_line(source or "") + if reference: + provenance.append(f"source: {reference}") + parts.append(f" ({', '.join(provenance)})") + return "".join(parts) + + +def capture( + home: Path, + project: str, + decision: str, + *, + why: Optional[str] = None, + source: Optional[str] = None, + agent: Optional[str] = None, + now: Optional[dt.datetime] = None, + dry_run: bool = False, +) -> Dict[str, Any]: + """Append ``decision`` to the project's decision log, newest first under today's UTC heading.""" + home = Path(home).expanduser().absolute() + layout.validate_project_name(project) + memory = layout.project_memory_dir(home, project) + path = memory / DECISION_FILE + moment = now or dt.datetime.now(dt.timezone.utc) + agent_name = agent or default_agent() + entry = format_entry(decision, why=why, agent=agent_name, source=source, now=moment) + date = _iso(moment)[:10] + + link = _linked_component(home, path) + if link is not None: + raise WorkflowError(f"{link} is a symlink; the workflow never writes through a link") + if not memory.is_dir(): + raise WorkflowError( + f"project {project!r} has no memory tier at {memory}; create it with `agent-memory init --project {project}`" + ) + try: + info = os.lstat(path) + except FileNotFoundError: + raise WorkflowError(f"{path} is missing; recreate it with `agent-memory init --project {project}`") from None + except OSError as exc: + raise WorkflowError(f"cannot inspect {path}: {exc}") from exc + if not stat_mod.S_ISREG(info.st_mode): + raise WorkflowError(f"{path} is not a regular file") + try: + before = path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise WorkflowError(f"cannot read {path}: {exc}") from exc + + after = insert_entry(before, date, entry) + result = { + "schema_version": SCHEMA_VERSION, + "action": "capture", + "home": str(home), + "project": project, + "path": str(path), + "date": date, + "entry": entry, + "agent": agent_name, + "dry_run": dry_run, + "written": False, + } + if dry_run: + return result + _replace_text(path, after, mode=stat_mod.S_IMODE(info.st_mode)) + result["written"] = True + return result + + +def insert_entry(text: str, date: str, entry: str) -> str: + """``text`` with ``entry`` as the first item under the ``## date`` heading, creating the heading newest-first.""" + lines = text.split("\n") + if lines and lines[-1] == "": + lines.pop() # the trailing newline; restored below + first = next((index for index, line in enumerate(lines) if _HEADING.match(line)), None) + if first is not None and _HEADING.match(lines[first]).group(1) == date: + at = first + 1 + if at < len(lines) and lines[at] == "": + at += 1 + lines[at:at] = [entry] + else: + block = [f"## {date}", "", entry, ""] + if first is None: + if lines and lines[-1] != "": + lines.append("") + lines.extend(block[:-1]) + else: + at = first + if at > 0 and lines[at - 1] != "": + block.insert(0, "") + lines[at:at] = block + return "\n".join(lines) + "\n" + + +def _linked_component(home: Path, path: Path) -> Optional[Path]: + """The first symlink below ``home`` on the way down to ``path`` (``path`` included), or ``None``. + + The home itself may be a link (a workspace marker is one); every component + beneath it is inspected without following, so a linked ``projects/``, + project or memory directory is refused before anything is read or staged. + A component that does not exist ends the walk: the later checks name it. + """ + current = home + for part in path.relative_to(home).parts: + current = current / part + try: + info = os.lstat(current) + except (FileNotFoundError, NotADirectoryError): + return None + except OSError as exc: + raise WorkflowError(f"cannot inspect {current}: {exc}") from exc + if stat_mod.S_ISLNK(info.st_mode): + return current + return None + + +def _replace_text(path: Path, text: str, *, mode: int) -> None: + """Write ``text`` beside ``path`` and move it into place atomically; the file keeps its mode.""" + data = text.encode("utf-8") + temp = path.with_name(f".{path.name}.{secrets.token_hex(4)}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(temp, flags, mode) + except OSError as exc: + raise WorkflowError(f"cannot stage the write beside {path}: {exc}") from exc + try: + with os.fdopen(fd, "wb") as handle: + os.fchmod(handle.fileno(), mode) # os.open applied the umask; the original bits win + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, path) + except OSError as exc: + try: + os.unlink(temp) + except OSError: + pass + raise WorkflowError(f"cannot write {path}: {exc}") from exc + + +# --- recall -------------------------------------------------------------------- + + +def recall( + home: Path, + *, + project: Optional[str], + runtime: str = startup.RUNTIME_CLAUDE, + max_chars: int = startup.DEFAULT_MAX_CHARS, + home_source: str = "flag", + project_source: Optional[str] = None, + notes: List[str] = (), +) -> Dict[str, Any]: + """The bounded read: the manifest's readable files, in its order, with their content, cut at ``max_chars``.""" + manifest = startup.build_manifest( + home, runtime=runtime, project=project, home_source=home_source, project_source=project_source, notes=notes + ) + parts: List[str] = [] + files: List[Dict[str, Any]] = [] + head = f"agent-memory recall: home {manifest['home']}" + head += f", project {manifest['project']}" if manifest["project"] is not None else ", no project" + parts.append(head + f"; content follows in manifest order, cut at {max_chars} characters.") + for entry in manifest["files"]: + row = {"tier": entry["tier"], "relative": entry["relative"], "state": entry["state"], "bytes": entry["bytes"]} + if entry["state"] == startup.READABLE: + try: + content = Path(entry["path"]).read_bytes().decode("utf-8", errors="replace") + except OSError as exc: + row["state"] = startup.UNREADABLE + row["error"] = exc.strerror or str(exc) + manifest["warnings"].append(f"{entry['relative']}: unreadable ({row['error']})") + else: + parts.append(f"\n--- {entry['relative']} ({entry['bytes']} bytes, modified {entry['modified_at_utc']}) ---") + parts.append(content.rstrip("\n")) + files.append(row) + parts.append(f"\nExcluded by default: {', '.join(manifest['excluded'])}") + if manifest["warnings"]: + parts.append("Warnings:") + parts.extend(f" - {warning}" for warning in manifest["warnings"]) + text = _bound("\n".join(parts) + "\n", max_chars) + return { + "schema_version": SCHEMA_VERSION, + "action": "recall", + "home": manifest["home"], + "project": manifest["project"], + "content_injected": True, + "max_chars": max_chars, + "files": files, + "excluded": manifest["excluded"], + "warnings": manifest["warnings"], + "truncated": len(text) < len("\n".join(parts)) + 1, + "text": text, + } + + +def _bound(text: str, max_chars: int) -> str: + max_chars = max(0, max_chars) + if len(text) <= max_chars: + return text + suffix = f"\n[agent-memory: recall cut at {max_chars} characters; raise --max-chars or read the remaining files directly]\n" + if len(suffix) >= max_chars: + suffix = "[cut]\n"[:max_chars] + return text[: max_chars - len(suffix)] + suffix + + +def _one_line(text: str) -> str: + return _SPACE.sub(" ", text).strip() + + +def _iso(moment: dt.datetime) -> str: + return moment.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/tests/conformance/org_memory/cases/bad_layout/expected.json b/tests/conformance/org_memory/cases/bad_layout/expected.json new file mode 100644 index 0000000..1a3b3bf --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/expected.json @@ -0,0 +1,10 @@ +{ + "description": "Wrong nesting depth, underscore filename, month-dir mismatch, leading-dot project, uppercase session.", + "findings": [ + { + "name": "debriefs-layout", + "severity": "error", + "message_contains": "5 of 6" + } + ] +} diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md new file mode 100644 index 0000000..3b84380 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: .hidden +agent: alice +runtime: claude +session: abc12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md new file mode 100644 index 0000000..5c6f0ee --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: 1f3a9c2b +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md new file mode 100644 index 0000000..8e28157 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: ABCD +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md new file mode 100644 index 0000000..9fda75f --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: abc12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md new file mode 100644 index 0000000..5701bc4 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: def12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md new file mode 100644 index 0000000..9fda75f --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: abc12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/empty_store/expected.json b/tests/conformance/org_memory/cases/empty_store/expected.json new file mode 100644 index 0000000..7b6d68c --- /dev/null +++ b/tests/conformance/org_memory/cases/empty_store/expected.json @@ -0,0 +1,4 @@ +{ + "description": "Fresh-init store \u2014 .gitkeep only, nothing to validate.", + "findings": [] +} diff --git a/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep b/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json b/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json new file mode 100644 index 0000000..4fc4d2e --- /dev/null +++ b/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json @@ -0,0 +1,10 @@ +{ + "description": "org-memory exists but debriefs/ was never created.", + "findings": [ + { + "name": "debriefs-dir", + "severity": "warn", + "message_contains": "missing" + } + ] +} diff --git a/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md b/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md new file mode 100644 index 0000000..26fb610 --- /dev/null +++ b/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md @@ -0,0 +1 @@ +# Recent diff --git a/tests/conformance/org_memory/cases/staging_artifact/expected.json b/tests/conformance/org_memory/cases/staging_artifact/expected.json new file mode 100644 index 0000000..a444271 --- /dev/null +++ b/tests/conformance/org_memory/cases/staging_artifact/expected.json @@ -0,0 +1,10 @@ +{ + "description": "A lingering writer staging artifact beside a valid record \u2014 interrupted publication.", + "findings": [ + { + "name": "debriefs-staging", + "severity": "warn", + "message_contains": "interrupted publication" + } + ] +} diff --git a/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 new file mode 100644 index 0000000..72d4134 --- /dev/null +++ b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 @@ -0,0 +1 @@ +partial staged bytes \ No newline at end of file diff --git a/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md new file mode 100644 index 0000000..b222f13 --- /dev/null +++ b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: 1f3a9c2b +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: a9ec70f37ee6d325a05fee8206d65e2872eeb66c576377d0f271349007a09336 +immutable: true +--- +# Session debrief + +Interrupted-publication neighbor. diff --git a/tests/conformance/org_memory/cases/valid_store/expected.json b/tests/conformance/org_memory/cases/valid_store/expected.json new file mode 100644 index 0000000..962bdf7 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/expected.json @@ -0,0 +1,4 @@ +{ + "description": "Fully conforming store \u2014 canonical-grammar identities (hyphen/uppercase/dot/underscore agents and projects), unquoted and quoted timestamps.", + "findings": [] +} diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md new file mode 100644 index 0000000..2370740 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: Demo_Project +agent: bob-ops +runtime: claude +session: 9f00aa11 +started_utc: 2026-08-25T10:00:00Z +ended_utc: 2026-08-25T11:30:00Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md new file mode 100644 index 0000000..5c6f0ee --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: 1f3a9c2b +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md new file mode 100644 index 0000000..1d94a01 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo.project +agent: Alice_2.dev +runtime: claude +session: 00aa11bb +started_utc: 2026-07-01T09:00:00Z +ended_utc: 2026-07-01T09:45:00Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md new file mode 100644 index 0000000..459d303 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: other-project +agent: bob +runtime: claude +session: 9e0d44aa +started_utc: "2026-12-03T08:15:00Z" +ended_utc: "2026-12-03T09:00:00Z" +content_sha256: bd281cfd095deb397218f7e24aba3ce38f39c20ba4f82eb6b9d7947aac547436 +immutable: true +--- +# Session debrief + +Reviewed the gadget; two findings filed. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7ce049e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Shared fixtures: an isolated git environment, and a home that mirrors the golden.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Tuple + +import pytest + +from agent_memory import layout + + +@pytest.fixture +def git_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Git with no user or system config, a fixed default branch, and a fixed identity.""" + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "init.defaultBranch") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "main") + for role in ("AUTHOR", "COMMITTER"): + monkeypatch.setenv(f"GIT_{role}_NAME", "Memory Test") + monkeypatch.setenv(f"GIT_{role}_EMAIL", "memory-test@example.invalid") + + +def git(*args: str, cwd: Path) -> str: + completed = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True, check=False) + assert completed.returncode == 0, f"git {' '.join(args)} failed ({completed.returncode}): {completed.stdout}\n{completed.stderr}" + return completed.stdout.strip() + + +def write(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def synced_home(tmp_path: Path, name: str = "home") -> Tuple[Path, Path]: + """A memory home in the golden's state: marker, canonical allowlist, one canonical debrief, + one commit, a bare remote as upstream, clean and synced. Returns (home, remote).""" + home = tmp_path / name + remote = tmp_path / f"{name}-remote.git" + home.mkdir() + git("init", "--quiet", cwd=home) + git("init", "--bare", "--quiet", str(remote), cwd=tmp_path) + write(home / layout.MARKER_FILE, "memory sync enabled\n") + write(home / layout.GITIGNORE_FILE, layout.gitignore_text()) + write( + home / layout.ORG.pattern / "debriefs" / "demo-project" / "2026" / "09" / "20260905-alice-1f3a9c2b.md", + "---\nschema_version: 1\n---\nbody\n", + ) + git("add", "-f", layout.MARKER_FILE, layout.GITIGNORE_FILE, layout.ORG.pattern, cwd=home) + git("commit", "--quiet", "-m", "seed memory", cwd=home) + git("remote", "add", "origin", str(remote), cwd=home) + git("push", "--quiet", "-u", "origin", "main", cwd=home) + return home, remote diff --git a/tests/fixtures/setup/claude_settings_fleet.json b/tests/fixtures/setup/claude_settings_fleet.json new file mode 100644 index 0000000..4693f14 --- /dev/null +++ b/tests/fixtures/setup/claude_settings_fleet.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "command": "oacp-envelope-hook", + "timeout": 15, + "type": "command" + } + ], + "matcher": "Bash|Edit|Write|NotebookEdit" + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "command": ".claude/hooks/oacp-memory-push.sh", + "timeout": 30, + "type": "command" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": ".claude/hooks/oacp-memory-pull.sh", + "timeout": 30, + "type": "command" + } + ], + "matcher": "startup" + } + ] + } +} diff --git a/tests/fixtures/setup/codex_hooks_kernel.json b/tests/fixtures/setup/codex_hooks_kernel.json new file mode 100644 index 0000000..7c4ba5d --- /dev/null +++ b/tests/fixtures/setup/codex_hooks_kernel.json @@ -0,0 +1,29 @@ +{ + "description": "OACP startup verification for this workspace.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "additionalContextLimit": 4000, + "command": "oacp session-init --hook --pull-memory --project demo --hub-dir /home/user/oacp", + "statusMessage": "Checking OACP startup context", + "timeout": 60, + "type": "command" + } + ], + "matcher": "^startup$" + }, + { + "hooks": [ + { + "command": "./scripts/custom-startup.sh", + "timeout": 5, + "type": "command" + } + ], + "matcher": "^startup$" + } + ] + } +} diff --git a/tests/golden/canonical_memory_gitignore.txt b/tests/golden/canonical_memory_gitignore.txt new file mode 100644 index 0000000..d4bde11 --- /dev/null +++ b/tests/golden/canonical_memory_gitignore.txt @@ -0,0 +1,9 @@ +* +!*/ +!.gitignore +!.oacp-memory-repo +!org-memory/** +!projects/*/memory/** +projects/*/memory/.cache/ +# never sync private key material — explicit deny, wins over any future allowlist widening +keys/ diff --git a/tests/golden/debrief_record.md b/tests/golden/debrief_record.md new file mode 100644 index 0000000..aa307ae --- /dev/null +++ b/tests/golden/debrief_record.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: 'demo-project' +agent: 'alice' +runtime: 'claude' +session: '1f3a9c2b' +started_utc: '2026-08-25T20:04:11Z' +ended_utc: '2026-08-25T22:01:47Z' +content_sha256: '00bff923bcc4f0e2386633549f8c920590900f13e647d85ba6d9db6fbbeef6d1' +immutable: true +--- +# Session + +Did the thing. diff --git a/tests/golden/doctor_memory_0.4.5.json b/tests/golden/doctor_memory_0.4.5.json new file mode 100644 index 0000000..af54b4f --- /dev/null +++ b/tests/golden/doctor_memory_0.4.5.json @@ -0,0 +1,125 @@ +{ + "has_errors": false, + "fixed": [], + "categories": [ + { + "name": "Environment", + "worst_severity": "ok", + "results": [ + { + "name": "git", + "severity": "ok", + "message": "git \u2014 git version 2.55.0" + }, + { + "name": "python3", + "severity": "ok", + "message": "python3 \u2014 Python 3.11.16" + }, + { + "name": "gh", + "severity": "ok", + "message": "gh \u2014 gh version 2.100.0 (2026-09-03)" + }, + { + "name": "ruff", + "severity": "ok", + "message": "ruff \u2014 ruff 0.16.2" + }, + { + "name": "shellcheck", + "severity": "ok", + "message": "shellcheck \u2014 ShellCheck - shell script analysis tool" + }, + { + "name": "pyyaml", + "severity": "ok", + "message": "pyyaml \u2014 available" + } + ] + }, + { + "name": "Org Memory", + "worst_severity": "ok", + "results": [ + { + "name": "debriefs-dir", + "severity": "ok", + "message": "org-memory/debriefs/ \u2014 present" + }, + { + "name": "debriefs-layout", + "severity": "ok", + "message": "75 debrief file(s) \u2014 canonical layout" + } + ] + }, + { + "name": "Agent Registry", + "worst_severity": "ok", + "results": [ + { + "name": "registry", + "severity": "ok", + "message": "3 agent(s), 35 project membership(s) \u2014 registered" + } + ] + }, + { + "name": "Memory Sync", + "worst_severity": "ok", + "results": [ + { + "name": "memory-marker", + "severity": "ok", + "message": ".oacp-memory-repo \u2014 present" + }, + { + "name": "root-gitignore", + "severity": "ok", + "message": ".gitignore \u2014 canonical memory allowlist" + }, + { + "name": "tracked-allowlist", + "severity": "ok", + "message": "tracked files \u2014 932 inside memory allowlist" + }, + { + "name": "untracked-memory", + "severity": "ok", + "message": "untracked memory files \u2014 none" + }, + { + "name": "working-tree", + "severity": "ok", + "message": "working tree \u2014 clean" + }, + { + "name": "sync-state", + "severity": "ok", + "message": "sync state \u2014 synced with upstream" + }, + { + "name": "remote", + "severity": "ok", + "message": "remote \u2014 reachable" + }, + { + "name": "last-commit", + "severity": "ok", + "message": "last commit \u2014 fresh (0 day(s) old)" + }, + { + "name": "agents-tracked", + "severity": "ok", + "message": "agents/ tracked files \u2014 none" + }, + { + "name": "memory-overlays", + "severity": "ok", + "message": "memory .gitignore overlays \u2014 0 safe" + } + ] + } + ] +} diff --git a/tests/golden/doctor_memory_0.4.5.txt b/tests/golden/doctor_memory_0.4.5.txt new file mode 100644 index 0000000..fc8c2b8 --- /dev/null +++ b/tests/golden/doctor_memory_0.4.5.txt @@ -0,0 +1,28 @@ +[+] Environment + [+] git — git version 2.55.0 + [+] python3 — Python 3.11.16 + [+] gh — gh version 2.100.0 (2026-09-03) + [+] ruff — ruff 0.16.2 + [+] shellcheck — ShellCheck - shell script analysis tool + [+] pyyaml — available + +[+] Org Memory + [+] org-memory/debriefs/ — present + [+] 75 debrief file(s) — canonical layout + +[+] Agent Registry + [+] 3 agent(s), 35 project membership(s) — registered + +[+] Memory Sync + [+] .oacp-memory-repo — present + [+] .gitignore — canonical memory allowlist + [+] tracked files — 932 inside memory allowlist + [+] untracked memory files — none + [+] working tree — clean + [+] sync state — synced with upstream + [+] remote — reachable + [+] last commit — fresh (0 day(s) old) + [+] agents/ tracked files — none + [+] memory .gitignore overlays — 0 safe + +No issues found. diff --git a/tests/test_archive.py b/tests/test_archive.py new file mode 100644 index 0000000..de7fe0d --- /dev/null +++ b/tests/test_archive.py @@ -0,0 +1,650 @@ +from __future__ import annotations + +import datetime as dt +import errno +import json +import os +from pathlib import Path +from typing import Any, Callable, Dict, Tuple + +import pytest + +from agent_memory import archive, layout +from agent_memory.cli import main + +FIXED_NOW = dt.datetime(2026, 3, 20, 1, 2, 3, tzinfo=dt.timezone.utc) +ARCHIVED = "20260320T010203Z_notes.md" + + +@pytest.fixture +def project_root(tmp_path: Path) -> Tuple[Path, Path]: + """A home with one project whose memory and archive directories exist; returns (home, project dir).""" + home = tmp_path / "home" + project = home / "projects" / "demo" + (project / "memory" / "archive").mkdir(parents=True) + return home, project + + +def _stat(path: Path) -> Tuple[int, int, int]: + st = path.stat() + return st.st_mode, st.st_size, st.st_mtime_ns + + +# --- the ported behavior --------------------------------------------------- + + +def test_archive_moves_a_supplementary_file_into_the_archive(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("# notes\n", encoding="utf-8") + + result = archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + + destination = project / "memory" / "archive" / result["archived_file"] + assert result["status"] == "archived" + assert result["archived_file"] == ARCHIVED + assert not source.exists() + assert destination.read_text(encoding="utf-8") == "# notes\n" + assert result["source"] == str(source) and result["destination"] == str(destination) + + +def test_restore_moves_an_archived_file_back(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + archived = project / "memory" / "archive" / ARCHIVED + archived.write_text("# archived\n", encoding="utf-8") + + result = archive.restore(home, "demo", ARCHIVED) + + destination = project / "memory" / result["restored_file"] + assert result["status"] == "restored" + assert result["restored_file"] == "notes.md" + assert not archived.exists() + assert destination.read_text(encoding="utf-8") == "# archived\n" + + +def test_archive_rejects_a_missing_source(project_root: Tuple[Path, Path]) -> None: + home, _ = project_root + with pytest.raises(archive.ArchiveError, match="memory file not found"): + archive.archive(home, "demo", "notes.md") + + +def test_archive_rejects_an_existing_destination(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + (project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + (project / "memory" / "archive" / ARCHIVED).write_text("# existing\n", encoding="utf-8") + with pytest.raises(archive.ArchiveError, match="archive destination already exists"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + assert (project / "memory" / "archive" / ARCHIVED).read_text(encoding="utf-8") == "# existing\n" + assert (project / "memory" / "notes.md").is_file() + + +def test_restore_rejects_an_existing_active_destination(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + (project / "memory" / "archive" / ARCHIVED).write_text("# archived\n", encoding="utf-8") + (project / "memory" / "notes.md").write_text("# current\n", encoding="utf-8") + with pytest.raises(archive.ArchiveError, match="active memory destination already exists"): + archive.restore(home, "demo", ARCHIVED) + assert (project / "memory" / "notes.md").read_text(encoding="utf-8") == "# current\n" + + +def test_restore_rejects_a_missing_archive_directory(tmp_path: Path) -> None: + home = tmp_path / "home" + (home / "projects" / "demo" / "memory").mkdir(parents=True) + with pytest.raises(archive.ArchiveError, match="memory archive directory not found"): + archive.restore(home, "demo", ARCHIVED) + + +@pytest.mark.parametrize("bad", ["../notes.md", "a/b.md", "a\\b.md", ".hidden", "", "-dash", "x" * 129, "sp ace.md"]) +def test_archive_rejects_names_that_are_not_simple_basenames(project_root: Tuple[Path, Path], bad: str) -> None: + home, _ = project_root + with pytest.raises(archive.ArchiveError, match="simple basename"): + archive.archive(home, "demo", bad) + + +@pytest.mark.parametrize("bad", ["../demo", "a/b", ".hidden", ""]) +def test_archive_rejects_project_names_with_separators_or_leading_dots( + project_root: Tuple[Path, Path], bad: str +) -> None: + home, _ = project_root + with pytest.raises(archive.ArchiveError, match="project name must"): + archive.archive(home, bad, "notes.md") + + +@pytest.mark.parametrize("bad", ["notes.md", "2026_notes.md", "20260320T010203_notes.md", "20260320T010203Z_", "../x"]) +def test_restore_rejects_archived_names_off_the_grammar(project_root: Tuple[Path, Path], bad: str) -> None: + home, _ = project_root + with pytest.raises(archive.ArchiveError, match="simple basename|must match _"): + archive.restore(home, "demo", bad) + + +@pytest.mark.parametrize("protected", layout.PROJECT.files) +def test_archive_refuses_the_active_names(project_root: Tuple[Path, Path], protected: str) -> None: + home, project = project_root + (project / "memory" / protected).write_text("# active\n", encoding="utf-8") + with pytest.raises(archive.ArchiveError, match="cannot archive standard active memory file"): + archive.archive(home, "demo", protected) + with pytest.raises(archive.ArchiveError, match="cannot archive standard active memory file"): + archive.archive(home, "demo", protected, dry_run=True) + assert (project / "memory" / protected).is_file() + + +def test_the_protected_names_are_the_layout_table(project_root: Tuple[Path, Path]) -> None: + assert archive.PROTECTED_FILES == ("project_facts.md", "decision_log.md", "open_threads.md", "known_debt.md") + home, project = project_root + (project / "memory" / "archive" / "20260101T000000Z_open_threads.md").write_text("old\n", encoding="utf-8") + result = archive.restore(home, "demo", "20260101T000000Z_open_threads.md") + assert result["restored_file"] == "open_threads.md" + assert (project / "memory" / "open_threads.md").read_text(encoding="utf-8") == "old\n" + + +# --- dry-run parity -------------------------------------------------------- + + +def test_archive_dry_run_reports_the_same_paths_and_changes_nothing(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("# notes\n", encoding="utf-8") + + dry = archive.archive(home, "demo", "notes.md", dry_run=True, now=FIXED_NOW) + assert dry["status"] == "dry-run" and dry["dry_run"] is True + assert source.is_file() + assert not (project / "memory" / "archive" / ARCHIVED).exists() + + real = archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + assert {k: v for k, v in dry.items() if k not in ("status", "dry_run")} == { + k: v for k, v in real.items() if k not in ("status", "dry_run") + } + + +def test_restore_dry_run_reports_the_same_paths_and_changes_nothing(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + archived = project / "memory" / "archive" / ARCHIVED + archived.write_text("# archived\n", encoding="utf-8") + + dry = archive.restore(home, "demo", ARCHIVED, dry_run=True) + assert dry["status"] == "dry-run" + assert archived.is_file() and not (project / "memory" / "notes.md").exists() + + real = archive.restore(home, "demo", ARCHIVED) + assert {k: v for k, v in dry.items() if k not in ("status", "dry_run")} == { + k: v for k, v in real.items() if k not in ("status", "dry_run") + } + + +def test_dry_run_fails_on_everything_the_real_run_fails_on(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + with pytest.raises(archive.ArchiveError, match="memory file not found"): + archive.archive(home, "demo", "notes.md", dry_run=True) + (project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + (project / "memory" / "archive" / ARCHIVED).write_text("# existing\n", encoding="utf-8") + with pytest.raises(archive.ArchiveError, match="archive destination already exists"): + archive.archive(home, "demo", "notes.md", dry_run=True, now=FIXED_NOW) + with pytest.raises(archive.ArchiveError, match="active memory destination already exists"): + archive.restore(home, "demo", ARCHIVED, dry_run=True) + + +# --- no replace: the interleaving probes ------------------------------------ + + +def _interleave(monkeypatch: pytest.MonkeyPatch, plant: Callable[[], None]) -> None: + """Run ``plant`` at the instant of the move, after every check has passed and before the real link.""" + real_link = os.link + + def link(src: Any, dst: Any, **kwargs: Any) -> None: + plant() + real_link(src, dst, **kwargs) + + monkeypatch.setattr(archive.os, "link", link) + + +def test_archive_fails_closed_when_the_destination_appears_after_the_check( + project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("source\n", encoding="utf-8") + destination = project / "memory" / "archive" / ARCHIVED + _interleave(monkeypatch, lambda: destination.write_text("concurrent destination\n", encoding="utf-8")) + + with pytest.raises(archive.ArchiveError, match="destination already exists"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + + assert source.read_text(encoding="utf-8") == "source\n" + assert (project / "memory" / "archive" / ARCHIVED).read_text(encoding="utf-8") == "concurrent destination\n" + + +def test_restore_fails_closed_when_the_destination_appears_after_the_check( + project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + home, project = project_root + archived = project / "memory" / "archive" / ARCHIVED + archived.write_text("source\n", encoding="utf-8") + destination = project / "memory" / "notes.md" + _interleave(monkeypatch, lambda: destination.write_text("concurrent destination\n", encoding="utf-8")) + + with pytest.raises(archive.ArchiveError, match="destination already exists"): + archive.restore(home, "demo", ARCHIVED) + + assert archived.read_text(encoding="utf-8") == "source\n" + assert (project / "memory" / "notes.md").read_text(encoding="utf-8") == "concurrent destination\n" + + +def test_archive_fails_closed_when_a_symlink_appears_at_the_destination( + project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("source\n", encoding="utf-8") + elsewhere = tmp_path / "elsewhere.md" + elsewhere.write_text("elsewhere\n", encoding="utf-8") + _interleave(monkeypatch, lambda: (project / "memory" / "archive" / ARCHIVED).symlink_to(elsewhere)) + + with pytest.raises(archive.ArchiveError, match="destination already exists"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + + assert source.read_text(encoding="utf-8") == "source\n" + assert elsewhere.read_text(encoding="utf-8") == "elsewhere\n" + + +# --- containment: the symlink probes ----------------------------------------- + + +def test_archive_refuses_a_symlinked_memory_directory(tmp_path: Path) -> None: + outside = tmp_path / "outside-memory" + outside.mkdir() + (outside / "elsewhere.md").write_text("outside target\n", encoding="utf-8") + home = tmp_path / "home" + project = home / "projects" / "demo" + project.mkdir(parents=True) + (project / "memory").symlink_to(outside, target_is_directory=True) + + with pytest.raises(archive.ArchiveError, match="symlink"): + archive.archive(home, "demo", "elsewhere.md", now=FIXED_NOW) + + assert (outside / "elsewhere.md").read_text(encoding="utf-8") == "outside target\n" + assert not (outside / "archive").exists() + + +@pytest.mark.parametrize("link", ["projects", "projects/demo", "projects/demo/memory", "projects/demo/memory/archive"]) +def test_every_directory_below_the_home_must_be_real(tmp_path: Path, link: str) -> None: + real_home = tmp_path / "real-home" + real_project = real_home / "projects" / "demo" + (real_project / "memory" / "archive").mkdir(parents=True) + (real_project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + (real_project / "memory" / "archive" / ARCHIVED).write_text("# archived\n", encoding="utf-8") + home = tmp_path / "home" + target = real_home / link + linked = home / link + linked.parent.mkdir(parents=True, exist_ok=True) + linked.symlink_to(target, target_is_directory=True) + for part in Path(link).parents: + if part != Path(".") and not (real_home / part).exists(): + (real_home / part).mkdir(parents=True) + + with pytest.raises(archive.ArchiveError, match="symlink"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + with pytest.raises(archive.ArchiveError, match="symlink"): + archive.restore(home, "demo", ARCHIVED) + with pytest.raises(archive.ArchiveError, match="symlink"): + archive.archive(home, "demo", "notes.md", dry_run=True, now=FIXED_NOW) + + assert (real_project / "memory" / "notes.md").is_file() + assert (real_project / "memory" / "archive" / ARCHIVED).is_file() + + +def test_archive_and_restore_refuse_a_symlinked_file(project_root: Tuple[Path, Path], tmp_path: Path) -> None: + home, project = project_root + elsewhere = tmp_path / "elsewhere.md" + elsewhere.write_text("elsewhere\n", encoding="utf-8") + (project / "memory" / "notes.md").symlink_to(elsewhere) + (project / "memory" / "archive" / ARCHIVED).symlink_to(elsewhere) + + with pytest.raises(archive.ArchiveError, match="refusing to move a symlink"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + with pytest.raises(archive.ArchiveError, match="refusing to move a symlink"): + archive.restore(home, "demo", ARCHIVED) + + assert elsewhere.read_text(encoding="utf-8") == "elsewhere\n" + assert (project / "memory" / "notes.md").is_symlink() + assert (project / "memory" / "archive" / ARCHIVED).is_symlink() + + +def test_a_symlinked_home_itself_is_fine(tmp_path: Path) -> None: + real_home = tmp_path / "real-home" + project = real_home / "projects" / "demo" + (project / "memory" / "archive").mkdir(parents=True) + (project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + home = tmp_path / "home" + home.symlink_to(real_home, target_is_directory=True) + + result = archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + + assert result["status"] == "archived" + assert (project / "memory" / "archive" / ARCHIVED).is_file() + + +# --- the move itself --------------------------------------------------------- + + +def test_bytes_and_metadata_survive_archive_and_restore(project_root: Tuple[Path, Path]) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + payload = bytes(range(256)) * 3 + source.write_bytes(payload) + source.chmod(0o640) + os.utime(source, ns=(1_600_000_000_000_000_000, 1_500_000_000_123_456_789)) + before = _stat(source) + + archived = project / "memory" / "archive" / archive.archive(home, "demo", "notes.md", now=FIXED_NOW)["archived_file"] + assert archived.read_bytes() == payload + assert _stat(archived) == before + assert not source.exists() + + archive.restore(home, "demo", archived.name) + assert source.read_bytes() == payload + assert _stat(source) == before + assert not archived.exists() + + +def test_archive_creates_the_archive_directory_when_missing(tmp_path: Path) -> None: + home = tmp_path / "home" + memory = home / "projects" / "demo" / "memory" + memory.mkdir(parents=True) + (memory / "notes.md").write_text("# notes\n", encoding="utf-8") + dry = archive.archive(home, "demo", "notes.md", dry_run=True, now=FIXED_NOW) + assert not (memory / "archive").exists() + result = archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + assert (memory / "archive" / ARCHIVED).is_file() + assert result["destination"] == dry["destination"] + + +def test_a_filesystem_without_hard_links_is_refused( + project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("# notes\n", encoding="utf-8") + + def no_links(src: Any, dst: Any, **kwargs: Any) -> None: + raise OSError(errno.EPERM, "Operation not permitted") + + monkeypatch.setattr(archive.os, "link", no_links) + with pytest.raises(archive.ArchiveError, match="hard links"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + assert source.is_file() + assert not (project / "memory" / "archive" / ARCHIVED).exists() + + +def test_a_source_that_cannot_be_removed_is_reported_not_hidden( + project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("# notes\n", encoding="utf-8") + + def no_unlink(path: Any, *args: Any, **kwargs: Any) -> None: + raise OSError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(archive.os, "unlink", no_unlink) + with pytest.raises(archive.ArchiveError, match="source could not be removed"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + assert source.is_file() + assert (project / "memory" / "archive" / ARCHIVED).is_file() + + +def test_non_posix_platforms_are_refused(project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch) -> None: + home, project = project_root + (project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + monkeypatch.setattr(archive, "_PLATFORM", "nt") + for dry_run in (False, True): + with pytest.raises(archive.ArchiveError, match="POSIX"): + archive.archive(home, "demo", "notes.md", dry_run=dry_run, now=FIXED_NOW) + with pytest.raises(archive.ArchiveError, match="POSIX"): + archive.restore(home, "demo", ARCHIVED, dry_run=dry_run) + assert (project / "memory" / "notes.md").is_file() + + +# --- the command line ------------------------------------------------------- + + +def test_archive_verb_json_output(project_root: Tuple[Path, Path], capsys: pytest.CaptureFixture[str]) -> None: + home, project = project_root + (project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + assert main(["archive", "demo", "notes.md", "--home", str(home), "--json"]) == 0 + payload: Dict[str, Any] = json.loads(capsys.readouterr().out) + assert payload["action"] == "archive" and payload["status"] == "archived" + assert Path(payload["destination"]).is_file() + assert payload["archived_file"].endswith("_notes.md") + + +def test_restore_verb_json_output(project_root: Tuple[Path, Path], capsys: pytest.CaptureFixture[str]) -> None: + home, project = project_root + (project / "memory" / "archive" / ARCHIVED).write_text("# archived\n", encoding="utf-8") + assert main(["restore", "demo", ARCHIVED, "--home", str(home), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["action"] == "restore" and payload["restored_file"] == "notes.md" + + +def test_archive_and_restore_verbs_human_output( + project_root: Tuple[Path, Path], capsys: pytest.CaptureFixture[str] +) -> None: + home, project = project_root + (project / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") + assert main(["archive", "demo", "notes.md", "--home", str(home), "--dry-run"]) == 0 + assert capsys.readouterr().out.startswith("Would archive memory/notes.md -> memory/archive/") + assert main(["archive", "demo", "notes.md", "--home", str(home)]) == 0 + out = capsys.readouterr().out + assert out.startswith("Archived memory/notes.md -> memory/archive/") + archived = out.split("memory/archive/")[1].strip() + assert main(["restore", "demo", archived, "--home", str(home)]) == 0 + assert capsys.readouterr().out == f"Restored memory/archive/{archived} -> memory/notes.md\n" + + +def test_archive_verb_refusals_exit_nonzero(project_root: Tuple[Path, Path], capsys: pytest.CaptureFixture[str]) -> None: + home, _ = project_root + assert main(["archive", "demo", "known_debt.md", "--home", str(home)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "agent-memory: error: cannot archive standard active memory file" in captured.err + assert main(["restore", "demo", "notes.md", "--home", str(home)]) == 1 + assert "must match _" in capsys.readouterr().err + + +# --- containment at the syscall boundary: a parent swapped after validation -- + + +_PARENT_LEVELS = ("projects", "projects/demo", "projects/demo/memory", "projects/demo/memory/archive") + + +def _tree(root: Path) -> Dict[str, Any]: + """Every entry below ``root`` with its kind and content, for a before/after comparison.""" + entries: Dict[str, Any] = {} + for path in sorted(root.rglob("*")): + key = str(path.relative_to(root)) + if path.is_symlink(): + entries[key] = ("symlink", os.readlink(path)) + elif path.is_dir(): + entries[key] = ("dir", None) + else: + entries[key] = ("file", path.read_bytes()) + return entries + + +@pytest.mark.parametrize("level", _PARENT_LEVELS) +@pytest.mark.parametrize("boundary", ["link", "unlink"]) +@pytest.mark.parametrize("action", ["archive", "restore"]) +def test_a_parent_swapped_for_a_symlink_after_validation_cannot_redirect_the_move( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, action: str, boundary: str, level: str +) -> None: + """Swap a checked parent for a symlink to an outside tree at the instant of the link or of the unlink. + + The outside tree holds a victim at the source's name. The move must complete inside the real tree + (the handles were taken before the swap) and neither write nor remove anything outside it. + """ + home = tmp_path / "home" + memory = home / "projects" / "demo" / "memory" + (memory / "archive").mkdir(parents=True) + source = memory / "notes.md" if action == "archive" else memory / "archive" / ARCHIVED + source.write_text("selected source\n", encoding="utf-8") + outside = tmp_path / "outside" + swap = home / level + if swap == memory / "archive": + redirected_memory, redirected_archive = memory, outside + else: + redirected_memory = outside / memory.relative_to(swap) + redirected_archive = redirected_memory / "archive" + redirected_archive.mkdir(parents=True) + victim = redirected_memory / "notes.md" if action == "archive" else redirected_archive / ARCHIVED + if victim != source: + victim.write_text("outside victim\n", encoding="utf-8") + outside_before = _tree(outside) + parked = tmp_path / "parked" + real = getattr(os, boundary) + + def interleave(*args: Any, **kwargs: Any) -> Any: + swap.rename(parked) + swap.symlink_to(outside, target_is_directory=True) + return real(*args, **kwargs) + + monkeypatch.setattr(archive.os, boundary, interleave) + if action == "archive": + result = archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + else: + result = archive.restore(home, "demo", ARCHIVED) + monkeypatch.undo() + + assert result["status"] == ("archived" if action == "archive" else "restored") + assert swap.is_symlink() + assert _tree(outside) == outside_before + if swap == memory / "archive": + real_memory, real_archive = memory, parked + else: + real_memory = parked / memory.relative_to(swap) + real_archive = real_memory / "archive" + moved_to = real_archive / ARCHIVED if action == "archive" else real_memory / "notes.md" + moved_from = real_memory / "notes.md" if action == "archive" else real_archive / ARCHIVED + assert moved_to.read_text(encoding="utf-8") == "selected source\n" + assert not moved_from.exists() + + +def test_a_symlink_planted_at_the_source_name_after_the_check_is_not_moved( + project_root: Tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The new link must be the regular file that was checked; a symlink planted at the source name meanwhile + is linked as itself (follow_symlinks=False), detected after the link, undone, and the move refused. + + A regular file planted at the name is not always distinguishable: some filesystems (ext4) reuse a freed + inode number at once, so that case is not asserted here. Either way the name lies inside the validated + memory directory, so containment is unaffected. + """ + home, project = project_root + source = project / "memory" / "notes.md" + source.write_text("checked\n", encoding="utf-8") + elsewhere = tmp_path / "elsewhere.md" + elsewhere.write_text("elsewhere\n", encoding="utf-8") + + def plant_symlink() -> None: + source.unlink() + source.symlink_to(elsewhere) + + _interleave(monkeypatch, plant_symlink) + with pytest.raises(archive.ArchiveError, match="changed after it was checked"): + archive.archive(home, "demo", "notes.md", now=FIXED_NOW) + + assert source.is_symlink() + assert elsewhere.read_text(encoding="utf-8") == "elsewhere\n" + assert not (project / "memory" / "archive" / ARCHIVED).exists() + + +# --- protected identity: the active files by inode, not by spelling ---------- + + +@pytest.mark.parametrize("protected", layout.PROJECT.files) +def test_a_name_that_addresses_an_active_file_on_this_filesystem_is_refused( + project_root: Tuple[Path, Path], protected: str +) -> None: + home, project = project_root + memory = project / "memory" + (memory / protected).write_text("# active\n", encoding="utf-8") + alternate = protected.upper() + if not (memory / alternate).exists(): + # case-sensitive filesystem: give the alternate spelling the same inode the way such a filesystem can + os.link(memory / protected, memory / alternate) + assert (memory / alternate).stat().st_ino == (memory / protected).stat().st_ino + + for dry_run in (True, False): + with pytest.raises(archive.ArchiveError, match="cannot archive standard active memory file"): + archive.archive(home, "demo", alternate, dry_run=dry_run, now=FIXED_NOW) + assert (memory / protected).read_text(encoding="utf-8") == "# active\n" + assert not list((memory / "archive").iterdir()) + + # the alternate spelling cannot be restored over the occupied slot either + (memory / "archive" / f"20260320T010203Z_{alternate}").write_text("old\n", encoding="utf-8") + with pytest.raises(archive.ArchiveError, match="active memory destination already exists"): + archive.restore(home, "demo", f"20260320T010203Z_{alternate}") + assert (memory / protected).read_text(encoding="utf-8") == "# active\n" + + # a supplementary file beside them still archives + (memory / "notes.md").write_text("notes\n", encoding="utf-8") + assert archive.archive(home, "demo", "notes.md", now=FIXED_NOW)["status"] == "archived" + + +# --- the archive path: absent, directory, regular file, symlink; dry run and real + + +@pytest.mark.parametrize("dry_run", [True, False]) +@pytest.mark.parametrize("state", ["absent", "directory", "regular_file", "symlink"]) +def test_the_archive_path_is_validated_before_dry_run_and_real_run_diverge( + tmp_path: Path, state: str, dry_run: bool +) -> None: + home = tmp_path / "home" + memory = home / "projects" / "demo" / "memory" + memory.mkdir(parents=True) + (memory / "notes.md").write_text("notes\n", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + if state == "directory": + (memory / "archive").mkdir() + elif state == "regular_file": + (memory / "archive").write_text("regular file\n", encoding="utf-8") + elif state == "symlink": + (memory / "archive").symlink_to(outside, target_is_directory=True) + + if state in ("absent", "directory"): + missing = "memory archive directory not found" if state == "absent" else "archived memory file not found" + with pytest.raises(archive.ArchiveError, match=missing): + archive.restore(home, "demo", ARCHIVED, dry_run=dry_run) + result = archive.archive(home, "demo", "notes.md", dry_run=dry_run, now=FIXED_NOW) + assert result["status"] == ("dry-run" if dry_run else "archived") + assert (memory / "archive" / ARCHIVED).is_file() is not dry_run + assert (memory / "archive").is_dir() is (state == "directory" or not dry_run) + else: + expected = "archive directory .* is not a directory" if state == "regular_file" else "archive directory .* is a symlink" + with pytest.raises(archive.ArchiveError, match=expected): + archive.archive(home, "demo", "notes.md", dry_run=dry_run, now=FIXED_NOW) + with pytest.raises(archive.ArchiveError, match=expected): + archive.restore(home, "demo", ARCHIVED, dry_run=dry_run) + assert (memory / "notes.md").read_text(encoding="utf-8") == "notes\n" + if state == "regular_file": + assert (memory / "archive").read_text(encoding="utf-8") == "regular file\n" + assert not list(outside.iterdir()) + + +def test_a_regular_file_at_the_archive_path_is_a_controlled_error_on_the_command_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + memory = home / "projects" / "demo" / "memory" + memory.mkdir(parents=True) + (memory / "notes.md").write_text("notes\n", encoding="utf-8") + (memory / "archive").write_text("regular file\n", encoding="utf-8") + + for extra in (["--dry-run"], []): + assert main(["archive", "demo", "notes.md", "--home", str(home), *extra]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.startswith("agent-memory: error: archive directory ") + assert "is not a directory" in captured.err and "Traceback" not in captured.err + assert main(["restore", "demo", ARCHIVED, "--home", str(home)]) == 1 + assert "is not a directory" in capsys.readouterr().err + assert (memory / "notes.md").read_text(encoding="utf-8") == "notes\n" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..61fd97f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import importlib.metadata +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +import agent_memory +from agent_memory import __version__, layout +from agent_memory.cli import main +from agent_memory.home import BINDING_FILE, ENV_COMPAT_HOME, ENV_HOME + +DISTRIBUTION = "agent-memory-cli" +CHECKOUT = Path(__file__).resolve().parents[1] +INSTALLED_ENV = "AGENT_MEMORY_TEST_INSTALLED" + + +@pytest.fixture +def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + monkeypatch.delenv(ENV_HOME, raising=False) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _installed_run() -> bool: + return os.environ.get(INSTALLED_ENV) == "1" + + +# --- the command line ------------------------------------------------------- + + +def test_version_flag(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + main(["--version"]) + assert exit_info.value.code == 0 + assert capsys.readouterr().out.strip() == f"agent-memory {__version__}" + + +def test_no_command_shows_help_and_fails(capsys: pytest.CaptureFixture[str]) -> None: + assert main([]) == 2 + assert "usage: agent-memory" in capsys.readouterr().err + + +def test_status_on_a_scaffolded_home(isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + layout.scaffold_home(home) + assert main(["status", "--home", str(home)]) == 0 + out = capsys.readouterr().out.splitlines() + assert out[0] == f"home: {home}" + assert "source: flag" in out + assert "exists: yes" in out + assert "marker: absent" in out + assert "gitignore: canonical" in out + assert "org-memory: present" in out + assert "projects: 0 with a memory dir" in out + + +def test_status_reports_marker_and_project_tiers(isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + layout.scaffold_home(home, project="demo") + (home / layout.MARKER_FILE).write_text("", encoding="utf-8") + assert main(["status", "--home", str(home)]) == 0 + out = capsys.readouterr().out + assert "marker: present" in out + assert "projects: 1 with a memory dir" in out + + +def test_status_on_a_missing_home_fails(isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["status", "--home", str(isolated / "nope")]) == 1 + assert "exists: no" in capsys.readouterr().out + + +def test_status_flags_a_non_canonical_gitignore(isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + layout.scaffold_home(home) + (home / layout.GITIGNORE_FILE).write_text("*\n", encoding="utf-8") + assert main(["status", "--home", str(home)]) == 0 + assert "gitignore: present, differs from canonical" in capsys.readouterr().out + + +def test_status_without_home_flag_uses_the_resolver( + isolated: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + home = isolated / "env-home" + layout.scaffold_home(home) + monkeypatch.setenv(ENV_HOME, str(home)) + assert main(["status"]) == 0 + assert f"source: env:{ENV_HOME}" in capsys.readouterr().out + + +def test_status_reports_the_binding_project(isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "bound-home" + layout.scaffold_home(home) + (isolated / BINDING_FILE).write_text( + '{"schema_version": 1, "project": "demo", "home": "bound-home"}', encoding="utf-8" + ) + assert main(["status"]) == 0 + out = capsys.readouterr().out + assert "project: demo" in out + assert f"source: binding:{isolated / BINDING_FILE}" in out + + +def test_malformed_binding_is_a_usage_error(isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + (isolated / BINDING_FILE).write_text("{", encoding="utf-8") + assert main(["status"]) == 2 + assert "agent-memory: error:" in capsys.readouterr().err + + +@pytest.mark.parametrize("kind", ["dangling symlink", "directory"]) +def test_broken_binding_entry_is_a_usage_error_not_a_fallthrough( + isolated: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + layout.scaffold_home(isolated / "outer-home") + (isolated / BINDING_FILE).write_text('{"schema_version": 1, "home": "outer-home"}', encoding="utf-8") + repo = isolated / "repo" + repo.mkdir() + if kind == "dangling symlink": + (repo / BINDING_FILE).symlink_to(repo / "missing.json") + else: + (repo / BINDING_FILE).mkdir() + monkeypatch.chdir(repo) + assert main(["status"]) == 2 + captured = capsys.readouterr() + assert "agent-memory: error:" in captured.err + assert str(repo / BINDING_FILE) in captured.err + assert "outer-home" not in captured.out + + +def test_module_entry_point() -> None: + result = subprocess.run( + [sys.executable, "-m", "agent_memory", "--version"], capture_output=True, text=True, check=False + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"agent-memory {__version__}" + + +# --- the distribution ------------------------------------------------------- + + +def test_distribution_declares_no_runtime_dependencies() -> None: + requires = importlib.metadata.requires(DISTRIBUTION) or [] + runtime = [entry for entry in requires if "extra ==" not in entry] + assert runtime == [] + + +def test_distribution_version_matches_the_package() -> None: + assert importlib.metadata.version(DISTRIBUTION) == __version__ + + +def test_source_tree_names_the_kernel_only_where_allowed() -> None: + # The only permitted mentions: the compat env var line(s), the sync marker filename, + # setup/legacy.py (which retires the kernel's hooks by exact match and so must spell them), + # and the debrief writer's hidden --oacp-dir alias (the flag it carried before it became a verb). + src = CHECKOUT / "src" / "agent_memory" + if not src.is_dir(): + pytest.skip("no source checkout beside the tests") + legacy_table = src / "setup" / "legacy.py" + assert legacy_table.is_file() + token = re.compile("oacp", re.IGNORECASE) + offending = [] + alias_lines = 0 + for path in sorted(src.rglob("*.py")): + if path == legacy_table: + continue + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not token.search(line) or "OACP_HOME" in line or layout.MARKER_FILE in line: + continue + if '"--oacp-dir"' in line and path.name == "cli.py": + alias_lines += 1 + continue + offending.append(f"{path.relative_to(CHECKOUT)}:{number}: {line.strip()}") + assert offending == [] + assert alias_lines == 1 + + +def test_import_resolves_to_the_installed_wheel_when_asked() -> None: + if not _installed_run(): + pytest.skip(f"{INSTALLED_ENV}=1 not set: source-checkout run") + location = Path(agent_memory.__file__).resolve() + assert "site-packages" in location.parts + assert CHECKOUT not in location.parents + + +def test_console_script_runs_when_installed() -> None: + if not _installed_run(): + pytest.skip(f"{INSTALLED_ENV}=1 not set: source-checkout run") + exe = shutil.which("agent-memory") + assert exe, "agent-memory console script not on PATH" + result = subprocess.run([exe, "--version"], capture_output=True, text=True, check=False) + assert result.returncode == 0 + assert result.stdout.strip() == f"agent-memory {__version__}" + + +# --- the sync verbs --------------------------------------------------------- + + +def _git_out(*args: str, cwd: Path) -> str: + completed = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True, check=False) + assert completed.returncode == 0, completed.stderr + return completed.stdout.strip() + + +def test_enable_push_and_pull_verbs_round_trip(isolated: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None: + remote = isolated / "remote.git" + _git_out("init", "--bare", "--quiet", str(remote), cwd=isolated) + first = isolated / "first" + (first / "org-memory").mkdir(parents=True) + (first / "org-memory" / "recent.md").write_text("# recent\n", encoding="utf-8") + + assert main(["enable", "--home", str(first), "--remote", str(remote), "--agent", "claude"]) == 0 + out = capsys.readouterr().out + assert ".gitignore: created with the managed block." in out + assert "delivered to the remote" in out + assert _git_out("log", "-1", "--format=%s", cwd=first).startswith("memory: claude@") + + second = isolated / "second" + assert main(["clone", str(remote), "--home", str(second)]) == 0 + assert "Cloned the memory repository" in capsys.readouterr().out + + (first / "projects" / "demo" / "memory").mkdir(parents=True) + (first / "projects" / "demo" / "memory" / "open_threads.md").write_text("- open\n", encoding="utf-8") + assert main(["push", "--home", str(first), "--agent", "claude"]) == 0 + assert "committed 1 file(s)" in capsys.readouterr().out + + assert main(["pull", "--home", str(second)]) == 0 + assert capsys.readouterr().out.strip() == "memory pull: synced 1 commit(s)." + assert (second / "projects" / "demo" / "memory" / "open_threads.md").read_text(encoding="utf-8") == "- open\n" + + +def test_push_and_pull_verbs_are_silent_without_the_marker( + isolated: Path, git_env: None, capsys: pytest.CaptureFixture[str] +) -> None: + home = isolated / "home" + home.mkdir() + assert main(["push", "--home", str(home)]) == 0 + assert main(["pull", "--home", str(home)]) == 0 + captured = capsys.readouterr() + assert captured.out == "" and captured.err == "" + + +def test_refused_states_exit_nonzero_with_their_message_on_stderr( + isolated: Path, git_env: None, capsys: pytest.CaptureFixture[str] +) -> None: + outer = isolated / "outer" + outer.mkdir() + _git_out("init", "--quiet", cwd=outer) + nested = outer / "home" + nested.mkdir() + assert main(["enable", "--home", str(nested)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "agent-memory: error: root mismatch" in captured.err + + home = isolated / "home" + assert main(["enable", "--home", str(home)]) == 0 + capsys.readouterr() + with (home / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write("!projects/*/memory/.cache/\n") + cache = home / "projects" / "demo" / "memory" / ".cache" + cache.mkdir(parents=True) + (cache / "index.json").write_text("{}\n", encoding="utf-8") + assert main(["push", "--home", str(home)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "outside the sync allowlist" in captured.err + + +def test_clone_verb_refuses_a_non_empty_home(isolated: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + home.mkdir() + (home / "keep.txt").write_text("keep\n", encoding="utf-8") + assert main(["clone", str(isolated / "missing.git"), "--home", str(home)]) == 1 + assert "agent-memory: error: refusing to clone into a non-empty home" in capsys.readouterr().err + assert (home / "keep.txt").is_file() + + +def test_disable_verb(isolated: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + assert main(["enable", "--home", str(home)]) == 0 + capsys.readouterr() + assert main(["disable", "--home", str(home)]) == 0 + assert "syncing is disabled" in capsys.readouterr().out + assert not (home / layout.MARKER_FILE).exists() + assert (home / ".git").is_dir() + + +def test_sync_verbs_resolve_the_home_like_status( + isolated: Path, git_env: None, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + home = isolated / "env-home" + monkeypatch.setenv(ENV_HOME, str(home)) + assert main(["enable"]) == 0 + assert (home / layout.MARKER_FILE).is_file() + assert (home / ".gitignore").read_bytes() == layout.gitignore_text().encode("utf-8") diff --git a/tests/test_debrief.py b/tests/test_debrief.py new file mode 100644 index 0000000..e0c7558 --- /dev/null +++ b/tests/test_debrief.py @@ -0,0 +1,1051 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Conformance tests for the debrief writer, ported whole from the skill script's suite. + +The memory layout spec ("Writer commit contract") requires writer conformance +tests to pin seven behaviors. Each is marked below with the ``CONFORMANCE`` +tag naming the mandated case: + +1. exception / short write before publish -> canonical path stays absent +2. interrupted publication recovery +3. read-back mismatch +4. retry after success (idempotent) +5. differing-content collision +6. symlink at target +7. concurrent identical and differing writers + +Beyond those, two classes are pinned because nothing downstream can catch +them: staging ownership (the writer must only ever publish an inode it +created itself) and frontmatter serialization (a record must say what it +was asked to say when a real YAML parser reads it back). The port adds the +golden (byte-identical to the script's record), the doctor's acceptance of +the produced layout, the hidden ``--oacp-dir`` alias, and the verb run from +the installed console script in an unrelated working directory. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import threading +from pathlib import Path + +import pytest + +from agent_memory import debrief as wd +from agent_memory import org, publication +from agent_memory.cli import main +from agent_memory.home import ENV_COMPAT_HOME, ENV_HOME + +GOLDEN = Path(__file__).resolve().parent / "golden" / "debrief_record.md" + +STARTED = "2026-08-25T20:04:11Z" +ENDED = "2026-08-25T22:01:47Z" +BODY = b"# Session\n\nDid the thing.\n" + + +def call(home: Path, *, body: bytes = BODY, session: str = "1f3a9c2b", **kw): + params = dict( + home=home, + project="demo-project", + agent="alice", + runtime="claude", + session=session, + started_utc=STARTED, + ended_utc=ENDED, + body=body, + ) + params.update(kw) + return wd.write_debrief(**params) + + +def expected_path(home: Path, session: str = "1f3a9c2b") -> Path: + return home / "org-memory" / "debriefs" / "demo-project" / "2026" / "08" / f"20260825-alice-{session}.md" + + +def cli_args(home: Path, body_file: Path, *extra: str) -> list: + return [ + "debrief", "write", + "--project", "demo-project", "--agent", "alice", "--runtime", "claude", + "--session", "1f3a9c2b", "--started-utc", STARTED, "--ended-utc", ENDED, + "--body-file", str(body_file), "--home", str(home), *extra, + ] + + +# ---------------------------------------------------------------- golden --- + + +def test_record_is_byte_identical_to_the_script_golden(tmp_path): + """The record the skill script composes for these inputs, captured once; the port must match it.""" + golden = GOLDEN.read_bytes() + assert call(tmp_path, dry_run=True).record == golden + result = call(tmp_path) + assert result.status == "published" + assert result.path.read_bytes() == golden + + +# ---------------------------------------------------------------- layout --- + + +def test_publishes_at_the_canonical_path(tmp_path): + target, status, digest, _ = call(tmp_path) + assert status == "published" + assert target == expected_path(tmp_path) + assert target.is_file() + assert digest == wd.content_sha256(BODY) + + +def test_frontmatter_carries_every_required_field(tmp_path): + target, _, _, _ = call(tmp_path) + fm, body = wd.split_record(target.read_bytes()) + assert body == BODY + for field in wd.REQUIRED_FRONTMATTER_ORDER: + assert field in fm, f"missing required frontmatter field {field}" + assert fm["schema_version"] == "1" + assert fm["immutable"] == "true" + assert fm["project"] == "demo-project" + assert fm["agent"] == "alice" + assert fm["runtime"] == "claude" + assert fm["session"] == "1f3a9c2b" + assert fm["started_utc"] == STARTED + assert fm["ended_utc"] == ENDED + + +def test_content_hash_covers_the_exact_body_bytes(tmp_path): + # Trailing whitespace and newlines are part of the body -- no normalization. + body = b"# Session\n\ntrailing spaces \n\n\n" + target, _, _, _ = call(tmp_path, body=body) + fm, stored = wd.split_record(target.read_bytes()) + assert stored == body + assert fm["content_sha256"] == wd.content_sha256(body) + + +def test_body_containing_a_frontmatter_delimiter_round_trips(tmp_path): + body = b"# Session\n\n---\n\nA horizontal rule lives here.\n" + target, _, _, _ = call(tmp_path, body=body) + fm, stored = wd.split_record(target.read_bytes()) + assert stored == body + assert fm["content_sha256"] == wd.content_sha256(body) + + +def test_filename_matches_the_protocol_grammar(tmp_path): + target, _, _, _ = call(tmp_path) + grammar = re.compile(r"^(?P\d{8})-(?P[A-Za-z0-9][A-Za-z0-9._-]{0,63})-(?P[a-z0-9]{1,32})\.md$") + match = grammar.match(target.name) + assert match is not None + # Session is the substring after the FINAL hyphen. + assert target.name.rsplit("-", 1)[1] == "1f3a9c2b.md" + # Directory segments agree with the filename date prefix. + assert target.parent.name == match.group("date")[4:6] + assert target.parent.parent.name == match.group("date")[0:4] + + +def test_hyphenated_agent_name_stays_parseable(tmp_path): + target, _, _, _ = call(tmp_path, agent="bob-ops", session="9f00aa11") + assert target.name == "20260825-bob-ops-9f00aa11.md" + assert target.name.rsplit("-", 1)[1] == "9f00aa11.md" + + +def test_doctor_accepts_the_produced_layout(tmp_path, capsys): + home = tmp_path / "home" + org.init(home) + assert call(home).status == "published" + assert call(home, session="1f3a9c2c", body=b"# Second\n").status == "published" + assert main(["doctor", "--home", str(home)]) == 0 + out = capsys.readouterr().out + assert "2 debrief file(s)" in out and "canonical layout" in out + + +# ------------------------------------------------------------ validation --- + + +@pytest.mark.parametrize( + "kwargs", + [ + {"session": "has-hyphen"}, + {"session": "UPPERCASE"}, + {"session": "x" * 33}, + {"session": ""}, + {"agent": ".hidden"}, + {"agent": "bad/slash"}, + {"project": ".dotted"}, + {"project": "with/slash"}, + {"started_utc": "2026-08-25 20:04:11"}, + {"ended_utc": "not-a-date"}, + {"body": b""}, + ], +) +def test_invalid_input_is_rejected_before_anything_is_written(tmp_path, kwargs): + with pytest.raises(wd.WriterError) as excinfo: + call(tmp_path, **kwargs) + assert excinfo.value.code == 1 + assert not (tmp_path / "org-memory").exists() + + +@pytest.mark.parametrize( + "field,value", + [ + ("project", "demo\nimmutable: false\nx"), + ("project", "demo\rx"), + ("project", "demo\x00x"), + ("agent", "alice\nrogue: true"), + ("runtime", "claude\nimmutable: false"), + ("runtime", ""), + ("runtime", "has space"), + ("session", "abc\ndef"), + ], +) +def test_control_characters_cannot_inject_frontmatter(tmp_path, field, value): + # A newline in any identity field would otherwise open a second frontmatter + # line and let a caller forge fields such as immutable: false. + with pytest.raises(wd.WriterError) as excinfo: + call(tmp_path, **{field: value}) + assert excinfo.value.code == 1 + assert not (tmp_path / "org-memory").exists() + + +def test_quote_in_an_identity_field_cannot_break_the_frontmatter(tmp_path): + # project's protocol rule permits an apostrophe; single-quoted YAML escapes + # it by doubling, and the value must survive a round trip intact. + target, _, _, _ = call(tmp_path, project="it's-a-project") + fm, _ = wd.split_record(target.read_bytes()) + assert fm["project"] == "it's-a-project" + assert fm["immutable"] == "true" + assert fm["schema_version"] == "1" + + +def test_yaml_scalar_refuses_control_characters_directly(tmp_path): + # Defense in depth: composition rejects even if validation is bypassed. + with pytest.raises(wd.WriterError, match="control characters"): + wd._yaml_scalar("demo\nimmutable: false") + + +def test_ended_before_started_is_rejected(tmp_path): + with pytest.raises(wd.WriterError) as excinfo: + call(tmp_path, started_utc=ENDED, ended_utc=STARTED) + assert excinfo.value.code == 1 + assert not (tmp_path / "org-memory").exists() + + +# ---- CONFORMANCE 1: exception / short write before publish ---------------- + + +def test_short_write_before_publish_leaves_canonical_absent(tmp_path, monkeypatch): + real_write = os.write + + def truncating_write(fd, data): + # Only truncate the record write itself; os.write is process-global and + # the test runner's own output must pass through untouched. + if isinstance(data, bytes) and data.startswith(b"---\n") and len(data) > 100: + return real_write(fd, data[: len(data) // 2]) + return real_write(fd, data) + + monkeypatch.setattr(os, "write", truncating_write) + with pytest.raises(wd.WriterError): + call(tmp_path) + + target = expected_path(tmp_path) + assert not target.exists(), "canonical path must stay absent on a failed publish" + leftovers = list(target.parent.glob(".stage.*")) + assert leftovers == [], "failed publish must clean up its own staging artifact" + + +def test_exception_before_publish_leaves_canonical_absent(tmp_path, monkeypatch): + def boom(*_a, **_kw): + raise OSError("disk on fire") + + monkeypatch.setattr(os, "link", boom) + with pytest.raises(OSError): + call(tmp_path) + + target = expected_path(tmp_path) + assert not target.exists() + assert list(target.parent.glob(".stage.*")) == [] + + +# ---- CONFORMANCE 2: interrupted publication recovery ---------------------- + + +def _record(body: bytes = BODY, session: str = "1f3a9c2b") -> bytes: + return wd.compose_record( + project="demo-project", agent="alice", runtime="claude", + session=session, started_utc=STARTED, ended_utc=ENDED, body=body, + ) + + +def _pin_staging_name(monkeypatch, target: Path, suffix: str = "pinned") -> Path: + """Force the writer onto a known staging name. + + The real nonce is unpredictable, which is the primary defense: nothing can + pre-create the path the writer is about to claim. Pinning it lets these + tests exercise the defense *behind* that one -- the ownership checks that + must hold even if an attacker could guess the name. The name is looked up + in the publication module, where the staging happens. + """ + pinned = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.{suffix}") + monkeypatch.setattr(publication, "staging_path", lambda _target: pinned) + return pinned + + +def test_retry_after_an_interrupted_run_publishes_and_sweeps_the_stale_stage(tmp_path): + # A crashed run left a complete stage behind. The retry publishes on its + # own fresh inode and leaves no staging debris. + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + record = _record() + stale = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.crashedrun") + stale.write_bytes(record) + + assert call(tmp_path).status == "published" + assert target.read_bytes() == record + assert list(target.parent.glob(".stage.*")) == [], "no staging debris survives" + + +def test_retry_after_a_partial_run_publishes_and_sweeps_the_partial_stage(tmp_path): + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + record = _record() + partial = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.crashedrun") + partial.write_bytes(record[: len(record) // 3]) + + assert call(tmp_path).status == "published" + assert target.read_bytes() == record + assert list(target.parent.glob(".stage.*")) == [], "no staging debris survives" + + +def test_the_sweep_never_removes_a_file_this_writer_does_not_own(tmp_path): + # The sweep is scoped by construction to this record's staging prefix, but + # it still refuses anything that is not a plain single-linked file of ours: + # those were never this writer's to delete. + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + outside = tmp_path / "outside.txt" + outside.write_bytes(b"not ours\n") + + symlinked = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.symlink") + symlinked.symlink_to(outside) + multilinked = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.multilink") + multilinked.write_bytes(b"partial") + os.link(multilinked, tmp_path / "someone-elses-name") + subdir = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.dir") + subdir.mkdir() + + assert call(tmp_path).status == "published" + survivors = sorted(p.name for p in target.parent.iterdir() if p.name.startswith(".stage.")) + assert survivors == [subdir.name, multilinked.name, symlinked.name] + assert outside.read_bytes() == b"not ours\n" + + +def test_staging_name_is_writer_unique_and_private(tmp_path): + target = expected_path(tmp_path) + names = {wd.staging_path(target).name for _ in range(64)} + assert len(names) == 64, "the nonce must be writer-unique, not derived from content" + for name in names: + assert name.startswith(".stage."), "staging name is outside the canonical namespace" + assert wd.staging_path(target).parent == target.parent, "stage lives beside its target" + + +# ---- staging ownership: never publish an inode we did not create ---------- + + +def test_a_symlink_at_the_staging_path_is_never_published(tmp_path, monkeypatch): + # The attack this closes: pre-place a symlink where the writer will stage, + # pointing at a file holding the exact record. Following it would publish a + # canonical "immutable" record that stays mutable through the shared inode. + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + record = _record() + external = tmp_path / "attacker.bin" + external.write_bytes(record) + + pinned = _pin_staging_name(monkeypatch, target) + pinned.symlink_to(external) + + with pytest.raises(wd.WriterError, match="cannot create staging file"): + call(tmp_path) + + assert not target.exists(), "canonical path stays absent" + assert pinned.is_symlink(), "the foreign artifact is left for the operator" + assert external.read_bytes() == record, "and is never written through" + + +def test_a_regular_file_at_the_staging_path_is_never_adopted(tmp_path, monkeypatch): + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + record = _record() + pinned = _pin_staging_name(monkeypatch, target) + pinned.write_bytes(record) # byte-identical, and still not ours + + with pytest.raises(wd.WriterError, match="cannot create staging file"): + call(tmp_path) + assert not target.exists() + + +def test_a_multi_link_staging_file_is_refused(tmp_path, monkeypatch): + # O_EXCL guarantees a fresh inode, so this can only happen if something + # hard-links the stage between the create and the publish. Publishing then + # would leave the record reachable -- and writable -- under another name. + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + shadow = tmp_path / "shadow" + real_open = os.open + + def linking_open(path, flags, mode=0o777, **kw): + fd = real_open(path, flags, mode, **kw) + if str(path).startswith(str(target.parent / wd.STAGE_PREFIX)): + os.link(path, shadow) + return fd + + monkeypatch.setattr(os, "open", linking_open) + with pytest.raises(wd.WriterError, match="links"): + call(tmp_path) + assert not target.exists(), "canonical path stays absent" + + +def test_publication_binds_to_the_inode_that_was_verified(tmp_path, monkeypatch): + # If the canonical name is taken by some other file between the staged + # verification and the link, the writer must report it rather than treat + # whatever landed there as its own record. + imposter = tmp_path / "imposter.md" + imposter.write_bytes(_record()) + real_link = os.link + + def hijacking_link(src, dst, **kw): + real_link(imposter, dst, **kw) + + monkeypatch.setattr(os, "link", hijacking_link) + # Every attempt is hijacked, so the writer restages three times, takes the + # imposter's name back down each time, and reports the exhausted retry. + with pytest.raises(wd.WriterError, match="consecutive attempts"): + call(tmp_path) + assert not os.path.lexists(expected_path(tmp_path)), "the name the hijacked link created is taken back down" + assert imposter.read_bytes() == _record(), "the imposter's own name is untouched" + + +# ---- the staging name swapped at the link (codex PR #19 r1, F-001) --------- +# Between the fstat that verified the stage and the link that publishes it, an +# actor with write access to the directory can turn the staging NAME into a +# hard link or a symlink to a foreign partial file. Foreign bytes are never the +# published record: bound to the descriptor, the link refuses the orphaned +# inode and nothing foreign is ever visible; bound to the name, the foreign +# file is visible from the link until the identity check takes the name back +# down (the narrower name-fallback contract, publication module docstring). +# Both read as a vanished stage: the writer restages and retries, so a one-shot +# swap ends in a clean publish and a persistent one in a reported failure with +# the canonical path absent. + + +def _swap_stage_at_link(monkeypatch, tmp_path, how, persist): + target = expected_path(tmp_path) + foreign = tmp_path / "foreign.bin" + foreign.write_bytes(b"PARTIAL FOREIGN RECORD") + stages, swaps = [], [] + real_staging_path, real_link = publication.staging_path, os.link + + def recording_staging_path(t): + stage = real_staging_path(t) + stages.append(stage) + return stage + + def swapping_link(src, dst, *args, **kw): + if persist == "always" or not swaps: + swaps.append(True) + stage = stages[-1] + os.unlink(stage) + if how == "hardlink": + real_link(foreign, stage) + else: + stage.symlink_to(foreign) + return real_link(src, dst, *args, **kw) + + monkeypatch.setattr(publication, "staging_path", recording_staging_path) + monkeypatch.setattr(publication.os, "link", swapping_link) + return target, foreign, swaps + + +@pytest.mark.parametrize("how", ["hardlink", "symlink"]) +@pytest.mark.parametrize("persist", ["once", "always"]) +@pytest.mark.parametrize("source", ["platform", "name"]) +def test_a_staging_name_swapped_at_the_link_is_never_the_published_record(tmp_path, monkeypatch, how, persist, source): + """Post-call state on both link sources: the verified record published, or + a reported failure with the canonical path absent. + + On the descriptor source (Linux) nothing foreign is ever visible. On the + name source the foreign file is visible from the link until the identity + check takes the name down, and this test does not observe that interval: + the name-fallback contract is the post-call state (publication module + docstring). + """ + if source == "name": + monkeypatch.setattr(publication, "LINK_VIA_FD", False) + target, foreign, swaps = _swap_stage_at_link(monkeypatch, tmp_path, how, persist) + record = _record() + if persist == "once": + result = call(tmp_path) + assert result.status == "published" + assert target.read_bytes() == record + assert os.lstat(target).st_ino != os.lstat(foreign).st_ino + assert os.lstat(target).st_nlink == 1 + assert len(swaps) == 1 + else: + with pytest.raises(wd.WriterError, match="consecutive attempts"): + call(tmp_path) + assert not os.path.lexists(target), "nothing foreign survives under the canonical name" + assert len(swaps) == publication.PUBLISH_ATTEMPTS + assert foreign.read_bytes() == b"PARTIAL FOREIGN RECORD", "the foreign file's own name is untouched" + assert list(target.parent.glob(".stage.*")) == [] + + +def test_the_published_record_is_the_only_name_for_its_inode(tmp_path): + target = call(tmp_path).path + assert os.lstat(target).st_nlink == 1, "a surviving second link would leave the immutable record writable elsewhere" + + +def _pin_staging_names(mp): + """Make every staging unlink fail, leaving the stage in place.""" + real_unlink = os.unlink + + def failing_unlink(path): + if os.path.basename(path).startswith(wd.STAGE_PREFIX): + raise PermissionError("staging name pinned") + real_unlink(path) + + mp.setattr(os, "unlink", failing_unlink) + + +def test_a_staging_name_that_cannot_be_released_is_a_reported_failure(tmp_path): + # Staging cleanup is best-effort and never raises, so a failed unlink is + # silent by design. It must not be silently *harmful*: a stage that + # survives leaves the published inode reachable -- and writable -- under a + # second name, which is the whole guarantee gone. + with pytest.MonkeyPatch.context() as mp: + _pin_staging_names(mp) + with pytest.raises(wd.WriterError, match="still has 2 links"): + call(tmp_path) + + +def test_a_retry_cannot_report_idempotent_while_a_writable_alias_survives(tmp_path): + """The byte-identical retry row, closed. + + Finding the stored bytes already correct proves nothing about the *shape* + of what is stored. A previous run can have landed the record and then + failed to release its staging name, leaving a second name for the canonical + inode -- writing through which rewrites the "immutable" record. So the + retry re-proves the invariant instead of trusting the byte comparison, and + reports failure for as long as the alias is still there. + """ + with pytest.MonkeyPatch.context() as mp: + _pin_staging_names(mp) + with pytest.raises(wd.WriterError, match="still has 2 links"): + call(tmp_path) + + target = expected_path(tmp_path) + alias = next(iter(target.parent.glob(".stage.*"))) + assert os.lstat(alias).st_ino == os.lstat(target).st_ino, "the stage aliases the record" + + # Still failing, still refused: the bytes match, the shape does not. + with pytest.raises(wd.WriterError, match="still has 2 links"): + call(tmp_path) + + # Cleanup works again: the retry removes the alias and only then succeeds. + result = call(tmp_path) + assert result.status == "idempotent" + assert os.lstat(result.path).st_nlink == 1 + assert list(result.path.parent.glob(".stage.*")) == [] + assert result.path.read_bytes() == _record() + + +def test_the_sweep_removes_an_alias_of_the_record_but_not_a_foreign_multi_link(tmp_path): + # Both are multi-link staging files. One is the record itself under a + # second name and must go; the other is somebody else's file that merely + # matches the prefix, and is not this writer's to delete. + with pytest.MonkeyPatch.context() as mp: + _pin_staging_names(mp) + with pytest.raises(wd.WriterError): + call(tmp_path) + + target = expected_path(tmp_path) + foreign = target.with_name(f"{wd.STAGE_PREFIX}{target.name}.foreign") + foreign.write_bytes(b"someone else's partial") + os.link(foreign, tmp_path / "their-own-name") + + assert call(tmp_path).status == "idempotent" + survivors = sorted(p.name for p in target.parent.glob(".stage.*")) + assert survivors == [foreign.name] + assert os.lstat(target).st_nlink == 1 + + +def test_a_symlink_at_the_canonical_path_is_refused_without_being_followed(tmp_path): + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + outside = tmp_path / "outside.md" + outside.write_bytes(b"untouched\n") + target.symlink_to(outside) + + with pytest.raises(wd.WriterError, match="symlink"): + call(tmp_path) + assert outside.read_bytes() == b"untouched\n" + + +# ---- CONFORMANCE 3: read-back mismatch ----------------------------------- + + +def test_read_back_mismatch_is_a_reported_failure(tmp_path, monkeypatch): + real_link = os.link + + def tampering_link(src, dst, *args, **kw): + # Publication lands the staged inode, but storage hands back different + # bytes than were verified. The inode binding cannot see this; only the + # read-back can. + real_link(src, dst, *args, **kw) + # dst is a name relative to a directory descriptor where the link is + # descriptor-bound, so tamper with the record through its known path. + with open(expected_path(tmp_path), "r+b") as fh: + fh.seek(-1, os.SEEK_END) + fh.write(b"X") + + monkeypatch.setattr(os, "link", tampering_link) + with pytest.raises(wd.WriterError, match="read-back mismatch"): + call(tmp_path) + + +def test_a_leading_dot_project_name_is_rejected(tmp_path): + for project in ("..", ".inf", ".hidden"): + with pytest.raises(wd.WriterError) as exc: + call(tmp_path, project=project) + assert exc.value.code == 1 + assert not (tmp_path / "org-memory").exists() + + +# ---- CONFORMANCE 4: retry after success (idempotent) ---------------------- + + +def test_retry_after_success_is_idempotent(tmp_path): + first_target, first_status, first_hash, _ = call(tmp_path) + assert first_status == "published" + stamp = first_target.stat().st_mtime_ns + + second_target, second_status, second_hash, _ = call(tmp_path) + assert second_status == "idempotent" + assert second_target == first_target + assert second_hash == first_hash + assert second_target.stat().st_mtime_ns == stamp, "record must not be rewritten" + assert list(first_target.parent.glob(".stage.*")) == [] + + +# ---- CONFORMANCE 5: differing-content collision --------------------------- + + +def test_differing_content_collision_is_a_hard_failure(tmp_path): + target, _, _, _ = call(tmp_path) + original = target.read_bytes() + + with pytest.raises(wd.WriterError, match="new session identifier"): + call(tmp_path, body=b"# Session\n\nDifferent content.\n") + + assert target.read_bytes() == original, "a published debrief is never replaced" + assert list(target.parent.glob(".stage.*")) == [] + + +def test_collision_recovery_is_a_new_session_identifier(tmp_path): + first, _, _, _ = call(tmp_path) + second, status, _, _ = call(tmp_path, body=b"# Session\n\nDifferent.\n", session="1f3a9c2c") + assert status == "published" + assert first.is_file() and second.is_file() + assert first != second + + +# ---- CONFORMANCE 6: symlink at target ------------------------------------ + + +def test_symlink_at_target_is_refused(tmp_path): + target = expected_path(tmp_path) + target.parent.mkdir(parents=True) + decoy = tmp_path / "elsewhere.md" + decoy.write_bytes(b"not a debrief\n") + target.symlink_to(decoy) + + with pytest.raises(wd.WriterError, match="symlink"): + call(tmp_path) + + assert decoy.read_bytes() == b"not a debrief\n", "writer must not write through the link" + assert target.is_symlink(), "the hostile target is left as found, not silently replaced" + + +def test_directory_at_target_is_refused(tmp_path): + target = expected_path(tmp_path) + target.mkdir(parents=True) + with pytest.raises(wd.WriterError, match="not a regular file"): + call(tmp_path) + + +# ---- CONFORMANCE 7: concurrent identical and differing writers ------------ + + +def _run_concurrently(fns): + results, errors = [], [] + barrier = threading.Barrier(len(fns)) + + def runner(fn): + try: + barrier.wait() + results.append(fn()) + except Exception as exc: # noqa: BLE001 - recorded for assertions + errors.append(exc) + + threads = [threading.Thread(target=runner, args=(fn,)) for fn in fns] + for t in threads: + t.start() + for t in threads: + t.join() + return results, errors + + +def test_concurrent_identical_writers_both_succeed(tmp_path): + results, errors = _run_concurrently([lambda: call(tmp_path)] * 4) + assert errors == [], f"identical concurrent writers must not fail: {errors}" + statuses = sorted(r.status for r in results) + assert statuses.count("published") == 1, "exactly one writer publishes" + assert statuses.count("idempotent") == 3, "the rest resolve as idempotent" + + target = expected_path(tmp_path) + assert target.is_file() + assert list(target.parent.glob(".stage.*")) == [] + + +def test_concurrent_differing_writers_leave_one_intact_record(tmp_path): + bodies = [b"# A\n", b"# B\n", b"# C\n"] + results, errors = _run_concurrently([(lambda b=b: call(tmp_path, body=b)) for b in bodies]) + assert len(results) == 1, "exactly one differing writer may publish" + assert len(errors) == len(bodies) - 1 + assert all(isinstance(e, wd.WriterError) for e in errors) + + target = expected_path(tmp_path) + _, stored = wd.split_record(target.read_bytes()) + assert stored in bodies, "the landed record is one writer's complete body" + assert list(target.parent.glob(".stage.*")) == [] + + +# ---- the vanished stage, pinned deterministically --------------------------- +# CI 2026-09-06 (Linux, four identical writers): a concurrent writer's +# post-publish sweep unlinked another writer's freshly created stage, and the +# fstat after the O_EXCL create reported 0 links, which the script's code read +# as a hard error. The three shapes below are the ones the writer must resolve. + + +def _sweep_during_staging(monkeypatch, land): + """Make the first fsync of a staging file act as a concurrent writer's sweep. + + The staging name is unlinked between this writer's O_EXCL create and its + fstat; with ``land`` given, that record is published at the canonical path + first, the way a real sweep is always preceded by a publication. + """ + stages = [] + real_staging_path, real_fsync = publication.staging_path, os.fsync + fired = [] + + def recording_staging_path(target): + stage = real_staging_path(target) + stages.append((stage, target)) + return stage + + def sweeping_fsync(fd): + real_fsync(fd) + if not fired: + fired.append(True) + stage, target = stages[-1] + if land is not None: + target.write_bytes(land) + os.unlink(stage) + + monkeypatch.setattr(publication, "staging_path", recording_staging_path) + monkeypatch.setattr(publication.os, "fsync", sweeping_fsync) + return fired + + +def test_a_stage_swept_before_its_fstat_resolves_as_idempotent(tmp_path, monkeypatch): + record = call(tmp_path, dry_run=True).record + fired = _sweep_during_staging(monkeypatch, land=record) + result = call(tmp_path) + assert fired + assert result.status == "idempotent" + assert expected_path(tmp_path).read_bytes() == record + assert list(expected_path(tmp_path).parent.glob(".stage.*")) == [] + + +def test_a_stage_swept_before_its_fstat_under_a_different_record_is_a_collision(tmp_path, monkeypatch): + other = b"# another writer's record\n" + fired = _sweep_during_staging(monkeypatch, land=other) + with pytest.raises(wd.WriterError, match="different record"): + call(tmp_path) + assert fired + assert expected_path(tmp_path).read_bytes() == other, "the landed record is never replaced" + assert list(expected_path(tmp_path).parent.glob(".stage.*")) == [] + + +def test_a_stage_swept_before_its_fstat_with_nothing_landed_is_retried(tmp_path, monkeypatch): + fired = _sweep_during_staging(monkeypatch, land=None) + result = call(tmp_path) + assert fired + assert result.status == "published" + assert expected_path(tmp_path).read_bytes() == call(tmp_path, dry_run=True).record + assert list(expected_path(tmp_path).parent.glob(".stage.*")) == [] + + +# ------------------------------------------------------------------- CLI --- + + +def test_cli_publishes_and_reports_json(tmp_path, capsys): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + assert main(cli_args(tmp_path, body_file, "--json")) == 0 + payload = json.loads(capsys.readouterr().out) + assert list(payload) == ["path", "status", "content_sha256", "schema_version"] + assert payload["status"] == "published" + assert payload["content_sha256"] == wd.content_sha256(BODY) + assert payload["schema_version"] == 1 + assert Path(payload["path"]) == expected_path(tmp_path) + + +def test_cli_text_output_names_status_path_and_hash(tmp_path, capsys): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + assert main(cli_args(tmp_path, body_file)) == 0 + out = capsys.readouterr().out + assert out == f"published: {expected_path(tmp_path)}\ncontent_sha256: {wd.content_sha256(BODY)}\n" + + +def test_cli_returns_one_on_validation_error(tmp_path, capsys): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + args = cli_args(tmp_path, body_file) + args[args.index("1f3a9c2b")] = "not-valid" + assert main(args) == 1 + assert "session" in capsys.readouterr().err + + +def test_cli_returns_one_on_an_unreadable_body(tmp_path, capsys): + assert main(cli_args(tmp_path, tmp_path / "missing.md")) == 1 + assert "cannot read body" in capsys.readouterr().err + assert not (tmp_path / "org-memory").exists() + + +def test_cli_returns_two_on_publication_failure(tmp_path, capsys): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + assert main(cli_args(tmp_path, body_file)) == 0 + body_file.write_bytes(b"# Different\n") + assert main(cli_args(tmp_path, body_file)) == 2 + assert "new session identifier" in capsys.readouterr().err + + +def test_cli_reads_the_body_from_stdin(tmp_path, capsys, monkeypatch): + import io + + monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(BODY))) + assert main(cli_args(tmp_path, Path("-"), "--json")) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "published" + assert expected_path(tmp_path).read_bytes() == GOLDEN.read_bytes() + + +def test_cli_accepts_the_hidden_oacp_dir_alias(tmp_path, capsys): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + store = tmp_path / "store" + args = cli_args(store, body_file, "--json") + args[args.index("--home")] = "--oacp-dir" + assert main(args) == 0 + assert Path(json.loads(capsys.readouterr().out)["path"]) == expected_path(store) + with pytest.raises(SystemExit) as exit_info: + main(["debrief", "write", "--help"]) + assert exit_info.value.code == 0 + help_text = capsys.readouterr().out + assert "--home" in help_text and "--oacp-dir" not in help_text + + +def test_cli_resolves_the_home_like_every_other_verb(tmp_path, capsys, monkeypatch): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + store = tmp_path / "env-store" + monkeypatch.setenv(ENV_HOME, str(store)) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + args = cli_args(store, body_file, "--json") + del args[args.index("--home") : args.index("--home") + 2] + assert main(args) == 0 + assert Path(json.loads(capsys.readouterr().out)["path"]) == expected_path(store) + + +def test_debrief_without_a_subcommand_shows_help(capsys): + assert main(["debrief"]) == 2 + assert "usage: agent-memory" in capsys.readouterr().err + + +# ------------------------------------------------- frontmatter serialization --- + +# Every one of these is a legal value under the protocol's identity grammars, +# and every one is a YAML plain-scalar keyword or indicator: emitted unquoted, +# a real parser reads them back as a bool, None, an int, or a syntax error -- +# silently changing the record's identity, including `immutable`. +YAML_HOSTILE_IDENTIFIERS = [ + "true", "True", "TRUE", "false", "False", "null", "Null", "NULL", + "yes", "no", "on", "off", "y", "n", "0x1f", "0o7", "1e3", "12", "1.0", +] + + +@pytest.mark.parametrize("value", YAML_HOSTILE_IDENTIFIERS) +def test_identity_values_survive_a_real_yaml_parser(tmp_path, value): + yaml = pytest.importorskip("yaml") + target = call(tmp_path, agent=value, session="1f3a9c2b").path + head = target.read_bytes().split(b"---\n")[1].decode("utf-8") + parsed = yaml.safe_load(head) + assert parsed["agent"] == value, f"{value!r} was re-typed to {parsed['agent']!r}" + assert isinstance(parsed["agent"], str) + assert parsed["immutable"] is True + assert parsed["schema_version"] == 1 + assert isinstance(parsed["content_sha256"], str) + + +@pytest.mark.parametrize("field", ["project", "agent", "runtime", "session"]) +def test_every_identity_field_is_quoted(tmp_path, field): + yaml = pytest.importorskip("yaml") + value = {"project": "true", "agent": "null", "runtime": "no", "session": "on"}[field] + target = call(tmp_path, **{field: value}).path + parsed = yaml.safe_load(target.read_bytes().split(b"---\n")[1].decode("utf-8")) + assert parsed[field] == value and isinstance(parsed[field], str) + + +@pytest.mark.parametrize( + "project", + ["*alias", "&anchor", "- item", "? key", "{a}", "[a]", "| block", "> fold", + "%directive", "@reserved", "`tick", "a: b", "a #c", "'quoted'", '"dquoted"', + "-1", "_", "~", "1.0", "0x1f"], +) +def test_yaml_indicator_project_names_stay_readable_strings(tmp_path, project): + yaml = pytest.importorskip("yaml") + target = call(tmp_path, project=project).path + parsed = yaml.safe_load(target.read_bytes().split(b"---\n")[1].decode("utf-8")) + assert parsed["project"] == project + + +@pytest.mark.parametrize("value", YAML_HOSTILE_IDENTIFIERS + ["*alias", "a: b", "'quoted'", "it's"]) +def test_identity_values_survive_the_writers_own_reader(tmp_path, value): + # The parser-independent half of the serialization pin: the writer's own + # split_record, which the read-back and the content hash are defined over, + # returns every hostile identifier as the string it was given. + target = call(tmp_path, project=value).path + fm, _ = wd.split_record(target.read_bytes()) + assert fm["project"] == value and fm["immutable"] == "true" and fm["schema_version"] == "1" + + +def test_composition_refuses_a_record_whose_frontmatter_does_not_round_trip(tmp_path, monkeypatch): + # The last line of defense: if the serializer ever regresses, composition + # fails loudly instead of publishing a record that says something else. + # Nothing downstream can catch this -- the read-back compares the stored + # file against these same composed bytes, and the doctor never opens + # debrief files. + monkeypatch.setattr(wd, "_yaml_scalar", str) + with pytest.raises(wd.WriterError, match="did not round-trip"): + call(tmp_path, agent="true") + assert not expected_path(tmp_path).exists() + + +@pytest.mark.parametrize("value", ["a", "A", "0", "z" * 64, "a.b", "a_b", "a-b", "A.0_z-Q", "true", "null"]) +def test_agent_grammar_boundaries_are_accepted_and_parseable(tmp_path, value): + result = call(tmp_path, agent=value) + assert result.path.name == f"20260825-{value}-1f3a9c2b.md" + stem = result.path.stem + assert stem.rsplit("-", 1)[1] == "1f3a9c2b", "session stays parseable after the final hyphen" + + +@pytest.mark.parametrize("value", ["", "z" * 65, ".hidden", "-lead", "a/b", "a b", "a\tb"]) +def test_agent_grammar_violations_are_rejected(tmp_path, value): + with pytest.raises(wd.WriterError) as exc: + call(tmp_path, agent=value) + assert exc.value.code == 1 + + +@pytest.mark.parametrize("value", ["", "z" * 33, "Abc", "a-b", "a_b", "a.b"]) +def test_session_grammar_violations_are_rejected(tmp_path, value): + with pytest.raises(wd.WriterError) as exc: + call(tmp_path, session=value) + assert exc.value.code == 1 + + +# ------------------------------------------------------------ body encoding --- + + +@pytest.mark.parametrize("body", [b"\xff\xfe", b"# ok\n\xc3\x28\n", b"\xed\xa0\x80", "ok\n".encode("utf-16")]) +def test_a_body_that_is_not_utf8_is_rejected_before_the_store_is_touched(tmp_path, body): + with pytest.raises(wd.WriterError, match="not valid UTF-8") as exc: + call(tmp_path, body=body) + assert exc.value.code == 1 + assert not (tmp_path / "org-memory").exists(), "no directories are created" + + +def test_multibyte_utf8_bodies_round_trip_byte_for_byte(tmp_path): + body = "# Sesión\n\n— ✅ 完了\n".encode("utf-8") + result = call(tmp_path, body=body) + _, stored = wd.split_record(result.path.read_bytes()) + assert stored == body + assert result.content_sha256 == wd.content_sha256(body) + + +# ----------------------------------------------------------------- dry run --- + + +def test_dry_run_composes_the_real_record_and_writes_nothing(tmp_path): + result = call(tmp_path, dry_run=True) + assert result.status == "dry-run" + assert result.path == expected_path(tmp_path) + assert result.record == _record() + assert not (tmp_path / "org-memory").exists(), "the store is not even created" + + +def test_dry_run_still_validates(tmp_path): + with pytest.raises(wd.WriterError) as exc: + call(tmp_path, session="not-valid", dry_run=True) + assert exc.value.code == 1 + + +def test_dry_run_then_publish_lands_the_previewed_bytes(tmp_path): + previewed = call(tmp_path, dry_run=True).record + published = call(tmp_path) + assert published.status == "published" + assert published.path.read_bytes() == previewed + + +def test_cli_dry_run_writes_nothing_and_reports_the_status(tmp_path, capsys): + body_file = tmp_path / "body.md" + body_file.write_bytes(BODY) + assert main(cli_args(tmp_path, body_file, "--dry-run", "--json")) == 0 + captured = capsys.readouterr() + assert json.loads(captured.out)["status"] == "dry-run" + assert BODY.decode() in captured.err, "the composed record is shown for review" + assert not (tmp_path / "org-memory").exists() + + +# ------------------------------------------------------------ installed verb --- + + +def test_the_verb_runs_from_an_unrelated_working_directory(tmp_path): + """The console script, from a working directory unrelated to the home, with no kernel and no env var.""" + project = tmp_path / "elsewhere" + project.mkdir() + body_file = project / "body.md" + body_file.write_bytes(BODY) + store = tmp_path / "store" + env = {key: value for key, value in os.environ.items() if key not in (ENV_HOME, ENV_COMPAT_HOME)} + args = [sys.executable, "-m", "agent_memory", *cli_args(store, body_file, "--json")] + + dry = subprocess.run(args + ["--dry-run"], cwd=project, capture_output=True, text=True, env=env) + assert dry.returncode == 0, dry.stderr + assert '"status": "dry-run"' in dry.stdout + assert not (store / "org-memory").exists(), "dry run wrote nothing" + + live = subprocess.run(args, cwd=project, capture_output=True, text=True, env=env) + assert live.returncode == 0, live.stderr + assert '"status": "published"' in live.stdout + assert expected_path(store).read_bytes() == GOLDEN.read_bytes() diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..e8c5ba7 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,742 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory doctor``: the two memory categories, row for row against the 0.4.5 golden. + +The org-memory checks are setup-level only: directory presence, canonical path +layout, staging leftovers, irregular entries. They never open a record. The +sync checks go through git alone. Neither reads memory content, and neither +changes a byte. +""" + +from __future__ import annotations + +import builtins +import datetime as dt +import hashlib +import io +import json +import os +import re +import shutil +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import pytest + +from agent_memory import doctor, layout, sync +from agent_memory.cli import main +from agent_memory.doctor import Severity, check_memory_sync, check_org_memory, run_doctor +from agent_memory.git_runner import EXIT_TIMEOUT, GitResult, run_git + +from conftest import git, synced_home, write + +TESTS = Path(__file__).resolve().parent +GOLDEN_TEXT = TESTS / "golden" / "doctor_memory_0.4.5.txt" +GOLDEN_JSON = TESTS / "golden" / "doctor_memory_0.4.5.json" +CASES_DIR = TESTS / "conformance" / "org_memory" / "cases" +CASE_NAMES = sorted(p.name for p in CASES_DIR.iterdir() if p.is_dir() and not p.name.startswith(".")) +MEMORY_CATEGORIES = ("Org Memory", "Memory Sync") +DIGITS = re.compile(r"\d+") + +not_root = pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") + + +def _rows(category: doctor.Category) -> List[Tuple[str, Severity]]: + return [(result.name, result.severity) for result in category.results] + + +def _by_name(category: doctor.Category) -> Dict[str, doctor.Result]: + return {result.name: result for result in category.results} + + +def _tree_digest(root: Path) -> Dict[str, str]: + digest: Dict[str, str] = {} + for path in sorted(root.rglob("*")): + if path.name == ".git" or ".git" in path.relative_to(root).parts: + continue + if path.is_symlink(): + digest[str(path.relative_to(root))] = f"-> {os.readlink(path)}" + elif path.is_file(): + digest[str(path.relative_to(root))] = hashlib.sha256(path.read_bytes()).hexdigest() + return digest + + +# --- parity with the 0.4.5 golden ------------------------------------------- + + +def _normalize(text: str) -> str: + return DIGITS.sub("N", text) + + +def _golden_categories() -> List[dict]: + data = json.loads(GOLDEN_JSON.read_text(encoding="utf-8")) + return [category for category in data["categories"] if category["name"] in MEMORY_CATEGORIES] + + +def _golden_text_blocks() -> Dict[str, List[str]]: + blocks: Dict[str, List[str]] = {} + current: Optional[str] = None + for line in GOLDEN_TEXT.read_text(encoding="utf-8").splitlines(): + if line[:3] in doctor.SYMBOL.values() and not line.startswith(" "): + current = line[4:] + blocks[current] = [line] + elif current and line.startswith(" "): + blocks[current].append(line) + return {name: block for name, block in blocks.items() if name in MEMORY_CATEGORIES} + + +def test_the_golden_is_the_0_4_5_memory_doctor_output() -> None: + # The fixture carries both memory categories, every row ok, and the JSON mirrors the text. + categories = {category["name"]: category for category in _golden_categories()} + assert set(categories) == set(MEMORY_CATEGORIES) + assert [row["name"] for row in categories["Org Memory"]["results"]] == ["debriefs-dir", "debriefs-layout"] + assert [row["name"] for row in categories["Memory Sync"]["results"]] == [ + "memory-marker", "root-gitignore", "tracked-allowlist", "untracked-memory", "working-tree", + "sync-state", "remote", "last-commit", "agents-tracked", "memory-overlays", + ] + assert all(row["severity"] == "ok" for category in categories.values() for row in category["results"]) + blocks = _golden_text_blocks() + for name, category in categories.items(): + assert [line[8:] for line in blocks[name][1:]] == [row["message"] for row in category["results"]] + + +def test_rows_match_the_golden_on_a_home_in_the_golden_state(tmp_path: Path, git_env: None) -> None: + # Same categories, same row names, severities and messages in the same order; only the counts differ, + # so digits are normalized on both sides. + home, _ = synced_home(tmp_path) + categories = run_doctor(home) + assert [category.name for category in categories] == list(MEMORY_CATEGORIES) + for mine, golden in zip(categories, _golden_categories()): + assert mine.name == golden["name"] + assert mine.worst_severity.value == golden["worst_severity"] + assert [(r.name, r.severity.value, _normalize(r.message)) for r in mine.results] == [ + (row["name"], row["severity"], _normalize(row["message"])) for row in golden["results"] + ] + + +def test_text_report_matches_the_golden_block_for_block(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + report = doctor.report(run_doctor(home)) + blocks = _golden_text_blocks() + mine: Dict[str, List[str]] = {} + for block in report.rstrip("\n").split("\n\n")[:-1]: + lines = block.splitlines() + mine[lines[0][4:]] = lines + assert set(mine) == set(blocks) + for name, lines in blocks.items(): + assert [_normalize(line) for line in mine[name]] == [_normalize(line) for line in lines] + assert report.rstrip("\n").split("\n\n")[-1] == "No issues found." + assert GOLDEN_TEXT.read_text(encoding="utf-8").rstrip("\n").endswith("No issues found.") + + +def test_json_report_matches_the_golden_shape(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + mine = doctor.to_json(run_doctor(home)) + golden = json.loads(GOLDEN_JSON.read_text(encoding="utf-8")) + assert mine["has_errors"] is golden["has_errors"] is False + assert mine["memory_lint"] is None + for category, wanted in zip(mine["categories"], _golden_categories()): + assert set(category) == set(wanted) == {"name", "worst_severity", "results"} + assert [set(row) for row in category["results"]] == [set(row) for row in wanted["results"]] + + +# --- Org Memory: the conformance cases and the unit checks ------------------- + + +@pytest.mark.parametrize("case_name", CASE_NAMES) +def test_conformance_case(case_name: str, tmp_path: Path) -> None: + case = CASES_DIR / case_name + expected = json.loads((case / "expected.json").read_text(encoding="utf-8")) + root = tmp_path / "home" + shutil.copytree(case / "org-memory", root / "org-memory") + + cat = check_org_memory(root) + + actual = sorted((r.name, r.severity.value) for r in cat.results if r.severity in (Severity.warn, Severity.error)) + wanted = sorted((finding["name"], finding["severity"]) for finding in expected.get("findings") or []) + assert actual == wanted, [f"{r.name}:{r.severity.value}:{r.message}" for r in cat.results] + for finding in expected.get("findings") or []: + needle = finding.get("message_contains") + if needle: + assert any(r.name == finding["name"] and needle in r.message for r in cat.results), ( + f"no {finding['name']} message containing {needle!r}" + ) + + +def _write_debrief(root: Path, name: str = "20260825-alice-1f3a9c2b.md") -> Path: + path = root / "org-memory" / "debriefs" / "demo-project" / "2026" / "08" / name + return write(path, "---\nschema_version: 1\n---\nbody\n") + + +def _org_rows(root: Path) -> List[Tuple[str, Severity]]: + return _rows(check_org_memory(root)) + + +def test_canonical_layout_passes(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + _write_debrief(root) + assert ("debriefs-layout", Severity.ok) in _org_rows(root) + + +def test_uninitialized_store_is_a_skip_with_the_init_hint(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + cat = check_org_memory(root) + assert _rows(cat) == [("org-memory-dir", Severity.skip)] + assert cat.results[0].fix_hint == "Run: agent-memory org init" + + +@not_root +def test_content_is_never_opened(tmp_path: Path) -> None: + # Setup-only contract: a record whose CONTENT is unreadable is still a clean setup. + root = tmp_path / "home" + root.mkdir() + record = _write_debrief(root) + os.chmod(record, 0o000) + try: + rows = _org_rows(root) + finally: + os.chmod(record, 0o644) + assert ("debriefs-layout", Severity.ok) in rows + assert not any(name == "debriefs-unreadable" for name, _ in rows) + + +def test_staging_artifact_reported(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + real = _write_debrief(root) + (real.parent / ".stage.20260825-alice-1f3a9c2b.md.a1b2").write_text("partial", encoding="utf-8") + assert ("debriefs-staging", Severity.warn) in _org_rows(root) + + +def test_symlinked_record_flagged(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + real = _write_debrief(root) + (real.parent / "20260825-alice-99zz00aa.md").symlink_to(real) + rows = _org_rows(root) + assert ("debriefs-irregular", Severity.error) in rows + # The regular record still passes the layout check. + assert ("debriefs-layout", Severity.ok) in rows + + +def test_symlinked_directory_flagged_and_not_traversed(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + _write_debrief(root) + outside = tmp_path / "outside" + outside.mkdir() + write(outside / "2026" / "08" / "20260825-mallory-00aa11bb.md", "foreign\n") + (root / "org-memory" / "debriefs" / "linked-project").symlink_to(outside, target_is_directory=True) + cat = check_org_memory(root) + assert ("debriefs-irregular", Severity.error) in _rows(cat) + assert "1 debrief file(s)" in _by_name(cat)["debriefs-layout"].message + + +@not_root +def test_unreadable_directory_is_not_a_clean_empty_store(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + real = _write_debrief(root) + blocked = real.parent.parent.parent # demo-project/ + os.chmod(blocked, 0o000) + try: + cat = check_org_memory(root) + finally: + os.chmod(blocked, 0o755) + assert ("debriefs-unreadable", Severity.error) in _rows(cat) + assert not any(r.name == "debriefs-layout" and "empty store" in r.message for r in cat.results) + + +@not_root +def test_a_store_that_cannot_be_inspected_is_an_error_not_uninitialized(tmp_path: Path) -> None: + # org-memory/ exists but cannot be entered: the debriefs probe is denied, which is neither + # "not initialized" nor "missing debriefs/" and never an empty store. + root = tmp_path / "home" + root.mkdir() + _write_debrief(root) + org_memory = root / layout.ORG.pattern + os.chmod(org_memory, 0o000) + try: + cat = check_org_memory(root) + finally: + os.chmod(org_memory, 0o755) + assert _rows(cat) == [("debriefs-dir", Severity.error)] + assert "could not be inspected" in cat.results[0].message + assert "Permission denied" in cat.results[0].message + + +def test_directory_classification_failure_surfaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # An is_symlink failure on a directory entry is reported, never raised. + root = tmp_path / "home" + root.mkdir() + _write_debrief(root) + real_is_symlink = Path.is_symlink + + def flaky(self: Path) -> bool: + if self.name == "demo-project": + raise PermissionError(13, "Permission denied", str(self)) + return real_is_symlink(self) + + monkeypatch.setattr(Path, "is_symlink", flaky) + cat = check_org_memory(root) + assert ("debriefs-unreadable", Severity.error) in _rows(cat) + assert not any(r.name == "debriefs-layout" and "empty store" in r.message for r in cat.results) + + +def test_record_classification_failure_surfaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # A stat failure while classifying a record lands in the unreadable row; other records still pass. + root = tmp_path / "home" + root.mkdir() + _write_debrief(root) + victim = _write_debrief(root, "20260825-bob-77aa88bb.md") + real_stat = Path.stat + + def flaky(self: Path, *args: object, **kwargs: object) -> os.stat_result: + if self.name == victim.name: + raise PermissionError(13, "Permission denied", str(self)) + return real_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", flaky) + rows = _org_rows(root) + assert ("debriefs-unreadable", Severity.error) in rows + assert ("debriefs-layout", Severity.ok) in rows + + +# --- Memory Sync ------------------------------------------------------------ + + +class ScriptedRunner: + """Answers git calls from a table, records every call with its timeout, and never touches a repository.""" + + def __init__(self, answers: Dict[Tuple[str, ...], Tuple[int, str]]) -> None: + self.answers = answers + self.calls: List[Tuple[Tuple[str, ...], Optional[float]]] = [] + + def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: + call = tuple(args) + self.calls.append((call, timeout)) + code, out = self.answers.get(call, (0, "")) + return GitResult(code, out if code == 0 else "", "" if code == 0 else out) + + def timed(self, *prefix: str) -> List[Optional[float]]: + return [timeout for call, timeout in self.calls if call[: len(prefix)] == prefix] + + +def _scripted_home(tmp_path: Path) -> Path: + home = tmp_path / "home" + write(home / layout.MARKER_FILE, "memory sync enabled\n") + write(home / layout.GITIGNORE_FILE, layout.gitignore_text()) + return home + + +def _clean(home: Path, **overrides: Tuple[int, str]) -> Dict[Tuple[str, ...], Tuple[int, str]]: + return {**CLEAN_ANSWERS, ("rev-parse", "--show-toplevel"): (0, str(home)), **{tuple(k.split()): v for k, v in overrides.items()}} + + +CLEAN_ANSWERS: Dict[Tuple[str, ...], Tuple[int, str]] = { + ("rev-parse", "--is-inside-work-tree"): (0, "true"), + ("ls-files",): (0, f"{layout.GITIGNORE_FILE}\n{layout.MARKER_FILE}"), + ("ls-files", "--others", "--exclude-standard"): (0, ""), + ("status", "--porcelain"): (0, ""), + ("remote",): (0, "origin"), + ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"): (0, "origin/main"), + ("fetch", "--quiet"): (0, ""), + ("rev-list", "--left-right", "--count", "HEAD...origin/main"): (0, "0 0"), + ("rev-parse", "--verify", "HEAD"): (1, ""), +} + + +def test_memory_sync_not_configured(tmp_path: Path) -> None: + cat = check_memory_sync(tmp_path) + assert cat.name == "Memory Sync" + assert _rows(cat) == [("memory-marker", Severity.skip)] + assert "not configured" in cat.results[0].message + assert cat.results[0].fix_hint == "Run: agent-memory enable [--remote URL]" + + +def test_memory_sync_marker_without_a_repository_stops_at_a_warning(tmp_path: Path, git_env: None) -> None: + home = _scripted_home(tmp_path) + cat = check_memory_sync(home) + assert _rows(cat) == [("memory-marker", Severity.ok), ("memory-git", Severity.warn)] + assert "not a git repository" in cat.results[1].message + + +def test_memory_sync_a_home_inside_another_repository_is_not_read_as_that_repository(tmp_path: Path, git_env: None) -> None: + outer = tmp_path / "outer" + outer.mkdir() + git("init", "--quiet", cwd=outer) + write(outer / "unrelated.txt", "dirty outer tree\n") + home = outer / "home" + write(home / layout.MARKER_FILE, "memory sync enabled\n") + write(home / layout.GITIGNORE_FILE, layout.gitignore_text()) + + cat = check_memory_sync(home) + + assert _rows(cat) == [("memory-marker", Severity.ok), ("memory-git", Severity.warn)] + assert str(outer.resolve()) in cat.results[1].message + assert "root of its own repository" in cat.results[1].message + + +def test_memory_sync_warns_for_tracked_agent_state(tmp_path: Path, git_env: None) -> None: + root = tmp_path / "home" + root.mkdir() + git("init", "--quiet", cwd=root) + write(root / layout.MARKER_FILE, "marker\n") + write(root / layout.GITIGNORE_FILE, layout.gitignore_text()) + write(root / "projects" / "demo" / "agents" / "codex" / "status.yaml", "busy\n") + git("add", "-f", layout.MARKER_FILE, layout.GITIGNORE_FILE, "projects/demo/agents/codex/status.yaml", cwd=root) + + cat = check_memory_sync(root) + messages = "\n".join(result.message for result in cat.results) + assert "tracked file(s) outside memory allowlist" in messages + assert "agents/ file(s) tracked" in messages + + +def test_memory_sync_warns_for_escaping_overlay(tmp_path: Path, git_env: None) -> None: + root = tmp_path / "home" + root.mkdir() + git("init", "--quiet", cwd=root) + write(root / layout.MARKER_FILE, "marker\n") + write(root / layout.GITIGNORE_FILE, layout.gitignore_text()) + write(root / "projects" / "demo" / "memory" / ".gitignore", "!../agents/**\n") + + overlay = _by_name(check_memory_sync(root))["memory-overlays"] + assert overlay.severity is Severity.warn + assert "escape memory" in overlay.message + + +def test_memory_sync_does_not_report_agents_clean_when_ls_files_fails(tmp_path: Path) -> None: + home = _scripted_home(tmp_path) + runner = ScriptedRunner({**_clean(home), ("ls-files",): (1, "boom")}) + results = _by_name(check_memory_sync(home, runner=runner)) + assert results["tracked-allowlist"].severity is Severity.warn + assert "boom" in results["tracked-allowlist"].message + assert "agents-tracked" not in results + + +def test_memory_sync_fetch_carries_the_network_timeout(tmp_path: Path) -> None: + home = _scripted_home(tmp_path) + runner = ScriptedRunner(_clean(home)) + check_memory_sync(home, runner=runner) + assert runner.timed("fetch", "--quiet") == [sync.NETWORK_TIMEOUT_SECONDS] + assert [call for call, timeout in runner.calls if timeout is not None] == [("fetch", "--quiet")] + + +def test_memory_sync_reports_a_fetch_timeout_as_unreachable(tmp_path: Path) -> None: + home = _scripted_home(tmp_path) + runner = ScriptedRunner({**_clean(home), ("fetch", "--quiet"): (EXIT_TIMEOUT, "git fetch --quiet: timed out after 30s")}) + results = _by_name(check_memory_sync(home, runner=runner)) + assert results["sync-state"].severity is Severity.warn + assert "timed out" in results["sync-state"].message + assert (results["remote"].severity, results["remote"].message) == (Severity.warn, "remote — not reachable") + + +def test_memory_sync_a_failed_status_readout_is_a_warning_not_a_pass(tmp_path: Path) -> None: + home = _scripted_home(tmp_path) + runner = ScriptedRunner({**_clean(home), ("status", "--porcelain"): (128, "fatal: index locked")}) + results = _by_name(check_memory_sync(home, runner=runner)) + assert results["working-tree"].severity is Severity.warn + assert "index locked" in results["working-tree"].message + assert "sync-state" not in results and "remote" not in results + + +@pytest.mark.parametrize( + ("answers", "expected"), + [ + ({("remote",): (0, "")}, ("local-only; no remote configured", Severity.ok, Severity.skip)), + ( + {("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"): (128, "")}, + ("remote exists but no upstream branch is configured", Severity.warn, Severity.ok), + ), + ({("rev-list", "--left-right", "--count", "HEAD...origin/main"): (0, "2 1")}, + ("DIVERGED from upstream (2 ahead, 1 behind)", Severity.warn, Severity.ok)), + ({("rev-list", "--left-right", "--count", "HEAD...origin/main"): (0, "0 3")}, + ("BEHIND upstream by 3 commit(s)", Severity.warn, Severity.ok)), + ({("rev-list", "--left-right", "--count", "HEAD...origin/main"): (0, "4 0")}, + ("ahead by 4 unpushed commit(s)", Severity.warn, Severity.ok)), + ({}, ("synced with upstream", Severity.ok, Severity.ok)), + ], + ids=["local-only", "no-upstream", "diverged", "behind", "ahead", "synced"], +) +def test_memory_sync_state_rows( + tmp_path: Path, answers: Dict[Tuple[str, ...], Tuple[int, str]], expected: Tuple[str, Severity, Severity] +) -> None: + home = _scripted_home(tmp_path) + results = _by_name(check_memory_sync(home, runner=ScriptedRunner({**_clean(home), **answers}))) + text, sync_severity, remote_severity = expected + assert results["sync-state"].message == f"sync state — {text}" + assert results["sync-state"].severity is sync_severity + assert results["remote"].severity is remote_severity + + +def test_memory_sync_last_commit_age(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + fresh = _by_name(check_memory_sync(home))["last-commit"] + assert (fresh.severity, fresh.message) == (Severity.ok, "last commit — fresh (0 day(s) old)") + later = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=doctor.STALE_MEMORY_DAYS + 1) + stale = _by_name(check_memory_sync(home, now=later))["last-commit"] + assert stale.severity is Severity.warn + assert stale.message == f"last commit — stale ({doctor.STALE_MEMORY_DAYS + 1} day(s) old)" + + +def test_memory_sync_root_gitignore_states(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + path = home / layout.GITIGNORE_FILE + + assert _by_name(check_memory_sync(home))["root-gitignore"].message == ".gitignore — canonical memory allowlist" + + path.write_text(layout.gitignore_text() + "*.swp\n.DS_Store\n", encoding="utf-8") + managed = _by_name(check_memory_sync(home))["root-gitignore"] + assert managed.severity is Severity.ok + assert managed.message == ".gitignore — canonical memory allowlist as a managed block; 2 other line(s) kept" + + path.write_text("*\n", encoding="utf-8") + drifted = _by_name(check_memory_sync(home))["root-gitignore"] + assert (drifted.severity, drifted.message) == (Severity.warn, ".gitignore — drifted from canonical memory allowlist") + + path.unlink() + missing = _by_name(check_memory_sync(home))["root-gitignore"] + assert (missing.severity, missing.message) == (Severity.warn, ".gitignore — missing canonical memory allowlist") + + +def test_the_doctor_opens_only_the_allowlist_files(tmp_path: Path, git_env: None, monkeypatch: pytest.MonkeyPatch) -> None: + # Memory content is never opened: with every file open spied on, a full run touches the root + # .gitignore and the project overlays and nothing else under the home (git reads in its own process). + home, _ = synced_home(tmp_path) + write(home / "projects" / "demo" / "memory" / "open_threads.md", "secret\n") + write(home / "projects" / "demo" / "memory" / ".gitignore", "!kept/\n") + git("add", "projects", cwd=home) + git("commit", "--quiet", "-m", "project memory", cwd=home) + git("push", "--quiet", cwd=home) + opened: List[Path] = [] + real_path_open, real_io_open = Path.open, io.open + + def spy_path_open(self: Path, *args: object, **kwargs: object): + opened.append(self) + return real_path_open(self, *args, **kwargs) + + def spy_io_open(file: object, *args: object, **kwargs: object): + if isinstance(file, (str, os.PathLike)): + opened.append(Path(file)) + return real_io_open(file, *args, **kwargs) + + monkeypatch.setattr(Path, "open", spy_path_open) + monkeypatch.setattr(io, "open", spy_io_open) + monkeypatch.setattr(builtins, "open", spy_io_open) + + categories = run_doctor(home) + + assert all(result.severity is Severity.ok for category in categories for result in category.results) + touched = sorted({path.resolve() for path in opened if home.resolve() in path.resolve().parents}) + assert touched == [home.resolve() / layout.GITIGNORE_FILE, home.resolve() / "projects" / "demo" / "memory" / ".gitignore"] + + +@not_root +def test_memory_sync_an_unreadable_overlay_is_a_warning_not_a_pass(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + overlay = write(home / "projects" / "demo" / "memory" / ".gitignore", "!kept/\n") + os.chmod(overlay, 0o000) + try: + row = _by_name(check_memory_sync(home))["memory-overlays"] + finally: + os.chmod(overlay, 0o644) + assert row.severity is Severity.warn + assert "could not be inspected" in row.message + assert "projects/demo/memory/.gitignore: Permission denied" in row.message + + +@not_root +@pytest.mark.parametrize("denied", ["projects", "projects/demo", "projects/demo/memory"]) +def test_memory_sync_an_overlay_behind_a_denied_directory_is_never_counted_safe( + tmp_path: Path, git_env: None, denied: str, capsys: pytest.CaptureFixture[str] +) -> None: + # The discovery, not only the read, can be denied: projects/, a project directory, or its + # memory directory without search permission hides an escaping overlay from a glob. The row + # says the check is incomplete; once access is restored the same home reports the escape. + home, _ = synced_home(tmp_path) + write(home / "projects" / "demo" / "memory" / ".gitignore", "!../../private\n") + blocked = home / denied + os.chmod(blocked, 0o000) + try: + row = _by_name(check_memory_sync(home))["memory-overlays"] + exit_code = main(["doctor", "--home", str(home), "--json"]) + finally: + os.chmod(blocked, 0o755) + assert row.severity is Severity.warn + assert "could not be inspected (overlay check incomplete)" in row.message + assert "Permission denied" in row.message + assert "safe" not in row.message + assert exit_code == 0 + reported = next( + r for c in json.loads(capsys.readouterr().out)["categories"] for r in c["results"] if r["name"] == "memory-overlays" + ) + assert reported["severity"] == "warn" + + restored = _by_name(check_memory_sync(home))["memory-overlays"] + assert restored.severity is Severity.warn + assert restored.message == "memory .gitignore overlays can escape memory/**: projects/demo/memory/.gitignore: !../../private" + + +def test_memory_sync_overlay_discovery_counts_only_project_memory_overlays(tmp_path: Path, git_env: None) -> None: + # Controls for the bounded walk: no projects/ at all, a file where a project would be, a project + # without memory/, and a memory/ without an overlay are all "0 safe"; only the real overlay counts. + home, _ = synced_home(tmp_path) + assert _by_name(check_memory_sync(home))["memory-overlays"].message == "memory .gitignore overlays — 0 safe" + + write(home / "projects" / "README.md", "not a project\n") + write(home / "projects" / "bare" / "notes.md", "no memory tier\n") + (home / "projects" / "quiet" / "memory").mkdir(parents=True) + assert _by_name(check_memory_sync(home))["memory-overlays"].message == "memory .gitignore overlays — 0 safe" + + write(home / "projects" / "demo" / "memory" / ".gitignore", "!kept/\n") + assert _by_name(check_memory_sync(home))["memory-overlays"].message == "memory .gitignore overlays — 1 safe" + + +def test_memory_sync_an_undecodable_root_gitignore_is_a_warning_not_a_crash(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + (home / layout.GITIGNORE_FILE).write_bytes(b"*\n\xff\n") + cat = check_memory_sync(home) + row = _by_name(cat)["root-gitignore"] + assert (row.severity, row.message) == (Severity.warn, ".gitignore — could not be read: not valid UTF-8") + assert row.fix_hint == "Re-encode the file as UTF-8" + assert "memory-overlays" in _by_name(cat) + + +def test_memory_sync_an_undecodable_overlay_is_a_warning_not_a_crash(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + overlay = home / "projects" / "demo" / "memory" / ".gitignore" + overlay.parent.mkdir(parents=True) + overlay.write_bytes(b"!kept/\n\xff\n") + row = _by_name(check_memory_sync(home))["memory-overlays"] + assert row.severity is Severity.warn + assert "projects/demo/memory/.gitignore: not valid UTF-8" in row.message + + +@pytest.mark.parametrize( + ("target", "row_name", "stops"), + [(layout.MARKER_FILE, "memory-marker", True), (layout.GITIGNORE_FILE, "root-gitignore", False)], +) +def test_memory_sync_a_denied_file_probe_is_a_warning_not_absent( + tmp_path: Path, git_env: None, monkeypatch: pytest.MonkeyPatch, target: str, row_name: str, stops: bool +) -> None: + # A stat the doctor is denied is not "missing": the marker row cannot say "not configured" and the + # root .gitignore row cannot say "missing allowlist" for a file that is there but unreadable. + home, _ = synced_home(tmp_path) + denied = home / target + real_stat = os.stat + + def flaky(path: object, *args: object, **kwargs: object) -> os.stat_result: + if isinstance(path, (str, os.PathLike)) and Path(path) == denied: + raise PermissionError(13, "Permission denied", str(path)) + return real_stat(path, *args, **kwargs) + + monkeypatch.setattr(os, "stat", flaky) + cat = check_memory_sync(home) + row = _by_name(cat)[row_name] + assert row.severity is Severity.warn + assert row.message == f"{target} — could not be inspected (setup check incomplete): Permission denied" + assert (len(cat.results) == 1) is stops + + +# --- the doctor changes nothing ----------------------------------------------- + + +def test_doctor_repairs_nothing(tmp_path: Path, git_env: None) -> None: + # A home with every warning and error the doctor knows is byte-identical after the run. + home, _ = synced_home(tmp_path) + (home / layout.GITIGNORE_FILE).write_text("*\n", encoding="utf-8") + write(home / "projects" / "demo" / "memory" / ".gitignore", "!../agents/**\n") + write(home / "projects" / "demo" / "memory" / "open_threads.md", "unpublished\n") + debriefs = home / layout.ORG.pattern / "debriefs" + (debriefs / "demo-project" / "2026" / "09" / ".stage.20260905-alice-1f3a9c2b.md.a1b2").write_text("partial", encoding="utf-8") + (debriefs / "demo-project" / "2026" / "09" / "20260905-alice-99zz00aa.md").symlink_to( + debriefs / "demo-project" / "2026" / "09" / "20260905-alice-1f3a9c2b.md" + ) + write(debriefs / "demo-project" / "20260905-alice-flat0000.md", "misplaced\n") + before = _tree_digest(home) + porcelain = git("status", "--porcelain", cwd=home) + + categories = run_doctor(home) + + assert _tree_digest(home) == before + assert git("status", "--porcelain", cwd=home) == porcelain + assert doctor.has_errors(categories) + names = {(category.name, result.name): result.severity for category in categories for result in category.results} + assert names[("Org Memory", "debriefs-staging")] is Severity.warn + assert names[("Org Memory", "debriefs-irregular")] is Severity.error + assert names[("Org Memory", "debriefs-layout")] is Severity.error + assert names[("Memory Sync", "root-gitignore")] is Severity.warn + assert names[("Memory Sync", "memory-overlays")] is Severity.warn + + +def test_an_unpublished_memory_file_is_a_warning_with_the_push_hint(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + write(home / "projects" / "demo" / "memory" / "open_threads.md", "unpublished\n") + row = _by_name(check_memory_sync(home))["untracked-memory"] + assert row.severity is Severity.warn + assert row.message == "1 untracked memory-shaped file(s): projects/demo/memory/open_threads.md" + assert row.fix_hint == "Run: agent-memory push" + + +# --- the report and the command line ---------------------------------------- + + +def test_report_prints_hints_for_every_row_that_is_not_ok(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + text = doctor.report(run_doctor(home)) + assert text == ( + "[-] Org Memory\n" + " [-] org-memory/ — not initialized\n" + " Run: agent-memory org init\n" + "\n" + "[-] Memory Sync\n" + " [-] .oacp-memory-repo — not configured; memory sync hooks are disabled\n" + " Run: agent-memory enable [--remote URL]\n" + "\n" + "No issues found.\n" + ) + + +def test_report_points_at_memory_lint_only_when_it_is_on_path(tmp_path: Path) -> None: + categories = run_doctor(tmp_path) + assert doctor.find_memory_lint(which=lambda name: None) is None + assert doctor.find_memory_lint(which=lambda name: f"/opt/bin/{name}") == "/opt/bin/memory-lint" + assert "memory-lint" not in doctor.report(categories) + pointer = doctor.report(categories, memory_lint="/opt/bin/memory-lint").splitlines()[-1] + assert pointer == "memory-lint is installed at /opt/bin/memory-lint; content checks (links, index rows, staleness) are its job." + assert doctor.to_json(categories, memory_lint="/opt/bin/memory-lint")["memory_lint"] == "/opt/bin/memory-lint" + + +def test_cli_exit_codes_follow_the_error_rows(tmp_path: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None: + home, _ = synced_home(tmp_path) + assert main(["doctor", "--home", str(home)]) == 0 + assert capsys.readouterr().out.rstrip("\n").endswith("No issues found.") + + (home / layout.GITIGNORE_FILE).write_text("*\n", encoding="utf-8") # a warning only + assert main(["doctor", "--home", str(home)]) == 0 + assert "[!] .gitignore — drifted" in capsys.readouterr().out + + record = home / layout.ORG.pattern / "debriefs" / "demo-project" / "2026" / "09" / "20260905-alice-1f3a9c2b.md" + (record.parent / "20260905-alice-99zz00aa.md").symlink_to(record) # an error row + assert main(["doctor", "--home", str(home)]) == 1 + assert capsys.readouterr().out.rstrip("\n").endswith("Doctor found issues that need attention.") + + +def test_cli_json_output(tmp_path: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None: + home, _ = synced_home(tmp_path) + assert main(["doctor", "--home", str(home), "--json"]) == 0 + data = json.loads(capsys.readouterr().out) + assert data["has_errors"] is False + assert [category["name"] for category in data["categories"]] == list(MEMORY_CATEGORIES) + + +def test_cli_refuses_a_missing_home(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["doctor", "--home", str(tmp_path / "nope")]) == 1 + assert "is not a directory" in capsys.readouterr().err + + +def test_default_runner_is_the_engine_runner() -> None: + assert doctor._git(Path("."), ["--version"], None) == run_git(["--version"], cwd=Path(".")) diff --git a/tests/test_home.py b/tests/test_home.py new file mode 100644 index 0000000..81fa94a --- /dev/null +++ b/tests/test_home.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from agent_memory.home import ( + BINDING_FILE, + DEFAULT_HOME, + ENV_COMPAT_HOME, + ENV_HOME, + HomeError, + find_workspace_marker, + load_binding, + resolve_home, +) + + +def _workspace(home: Path, project: str) -> Path: + workspace = home / "projects" / project / "workspace.json" + workspace.parent.mkdir(parents=True) + workspace.write_text("{}\n", encoding="utf-8") + return workspace + + +def _binding(directory: Path, **fields: object) -> Path: + path = directory / BINDING_FILE + path.write_text(json.dumps(fields), encoding="utf-8") + return path + + +def test_explicit_flag_wins_over_everything(tmp_path: Path) -> None: + env = {ENV_HOME: str(tmp_path / "env-home")} + found = resolve_home(str(tmp_path / "flag-home"), env=env, cwd=tmp_path) + assert found.path == tmp_path / "flag-home" + assert found.source == "flag" + + +def test_agent_memory_home_env(tmp_path: Path) -> None: + found = resolve_home(env={ENV_HOME: str(tmp_path / "h")}, cwd=tmp_path) + assert found.path == tmp_path / "h" + assert found.source == f"env:{ENV_HOME}" + + +def test_compat_env_recognized_but_outranked(tmp_path: Path) -> None: + compat_only = resolve_home(env={ENV_COMPAT_HOME: str(tmp_path / "compat")}, cwd=tmp_path) + assert compat_only.path == tmp_path / "compat" + assert compat_only.source == f"env:{ENV_COMPAT_HOME}" + both = resolve_home( + env={ENV_HOME: str(tmp_path / "own"), ENV_COMPAT_HOME: str(tmp_path / "compat")}, + cwd=tmp_path, + ) + assert both.path == tmp_path / "own" + + +def test_empty_env_values_fall_through(tmp_path: Path) -> None: + found = resolve_home(env={ENV_HOME: "", ENV_COMPAT_HOME: ""}, cwd=tmp_path) + assert found.source == "default" + + +def test_tilde_expands_against_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + found = resolve_home(env={ENV_HOME: "~/mem"}, cwd=tmp_path) + assert found.path == tmp_path / "mem" + assert resolve_home("~/flag", env={}, cwd=tmp_path).path == tmp_path / "flag" + + +def test_default_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + found = resolve_home(env={}, cwd=tmp_path) + assert found.path == tmp_path / "agent-memory" + assert found.source == "default" + assert DEFAULT_HOME == "~/agent-memory" + + +def test_binding_found_walking_up(tmp_path: Path) -> None: + repo = tmp_path / "repo" + deep = repo / "a" / "b" + deep.mkdir(parents=True) + binding = _binding(repo, schema_version=1, project="demo", home=str(tmp_path / "store")) + found = resolve_home(env={}, cwd=deep) + assert found.path == tmp_path / "store" + assert found.project == "demo" + assert found.source == f"binding:{binding}" + + +def test_binding_relative_home_is_relative_to_the_binding(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _binding(repo, schema_version=1, home="../store") + found = resolve_home(env={}, cwd=repo) + assert found.path == repo / "../store" + assert found.project is None + + +@pytest.mark.parametrize( + "content", + [ + "not json", + "[]", + json.dumps({"schema_version": 2, "home": "/x"}), + json.dumps({"schema_version": True, "home": "/x"}), # bool is not an int here, even though True == 1 + json.dumps({"schema_version": False, "home": "/x"}), + json.dumps({"schema_version": 1.0, "home": "/x"}), + json.dumps({"schema_version": "1", "home": "/x"}), + json.dumps({"schema_version": None, "home": "/x"}), + json.dumps({"home": "/x"}), + json.dumps({"schema_version": 1}), + json.dumps({"schema_version": 1, "home": ""}), + json.dumps({"schema_version": 1, "home": "/x", "project": 7}), + json.dumps({"schema_version": 1, "home": "/x", "project": ""}), + ], +) +def test_bad_binding_is_an_error_not_a_fallthrough(tmp_path: Path, content: str) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / BINDING_FILE).write_text(content, encoding="utf-8") + with pytest.raises(HomeError): + resolve_home(env={}, cwd=repo) + with pytest.raises(HomeError): + load_binding(repo / BINDING_FILE) + + +def test_nearest_binding_wins(tmp_path: Path) -> None: + _binding(tmp_path, schema_version=1, home=str(tmp_path / "outer-store")) + repo = tmp_path / "repo" + repo.mkdir() + _binding(repo, schema_version=1, home=str(tmp_path / "inner-store")) + assert resolve_home(env={}, cwd=repo).path == tmp_path / "inner-store" + assert resolve_home(env={}, cwd=tmp_path).path == tmp_path / "outer-store" + + +def test_binding_symlink_to_a_valid_file_is_followed(tmp_path: Path) -> None: + target = tmp_path / "shared" / "binding.json" + target.parent.mkdir() + target.write_text(json.dumps({"schema_version": 1, "project": "demo", "home": str(tmp_path / "store")})) + repo = tmp_path / "repo" + repo.mkdir() + link = repo / BINDING_FILE + link.symlink_to(target) + found = resolve_home(env={}, cwd=repo) + assert found.path == tmp_path / "store" + assert found.project == "demo" + assert found.source == f"binding:{link}" + + +BROKEN_ENTRY_KINDS = ("dangling symlink", "symlink loop", "directory", "symlink to a directory", "unreadable file") + + +def _broken_binding_entry(directory: Path, kind: str) -> Path: + """Put something at the binding's name that is not a readable file.""" + path = directory / BINDING_FILE + if kind == "dangling symlink": + path.symlink_to(directory / "missing.json") + elif kind == "symlink loop": + path.symlink_to(path) + elif kind == "directory": + path.mkdir() + elif kind == "symlink to a directory": + path.symlink_to(directory) + elif kind == "unreadable file": + path.write_text(json.dumps({"schema_version": 1, "home": str(directory / "store")}), encoding="utf-8") + path.chmod(0) + if os.access(path, os.R_OK): + pytest.skip("this user reads files regardless of their mode bits") + return path + + +@pytest.mark.parametrize("ancestor_binding", [True, False], ids=["with-ancestor-binding", "no-ancestor-binding"]) +@pytest.mark.parametrize("kind", BROKEN_ENTRY_KINDS) +def test_broken_nearer_binding_entry_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, kind: str, ancestor_binding: bool +) -> None: + """An unusable entry at the binding's name is an error; it never selects an ancestor or the default.""" + monkeypatch.setenv("HOME", str(tmp_path)) + if ancestor_binding: + _binding(tmp_path, schema_version=1, home=str(tmp_path / "outer-store")) + repo = tmp_path / "repo" + repo.mkdir() + path = _broken_binding_entry(repo, kind) + try: + with pytest.raises(HomeError, match=BINDING_FILE.replace(".", "[.]")): + resolve_home(env={}, cwd=repo) + with pytest.raises(HomeError): + load_binding(path) + if ancestor_binding: + assert resolve_home(env={}, cwd=tmp_path).path == tmp_path / "outer-store" + finally: + if kind == "unreadable file": + path.chmod(0o600) + + +def test_binding_outranks_marker(tmp_path: Path) -> None: + workspace = _workspace(tmp_path / "home", "demo") + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".oacp").symlink_to(workspace) + _binding(repo, schema_version=1, home=str(tmp_path / "bound")) + assert resolve_home(env={}, cwd=repo).path == tmp_path / "bound" + + +def test_marker_symlink_found_walking_up_whatever_its_name(tmp_path: Path) -> None: + workspace = _workspace(tmp_path / "home", "demo") + repo = tmp_path / "repo" + deep = repo / "src" / "pkg" + deep.mkdir(parents=True) + link = repo / ".oacp" + link.symlink_to(workspace) + found = resolve_home(env={}, cwd=deep) + assert found.path == (tmp_path / "home").resolve() + assert found.source == f"marker:{link}" + link.rename(repo / "any-name") + assert resolve_home(env={}, cwd=deep).path == (tmp_path / "home").resolve() + + +def test_nearest_marker_wins(tmp_path: Path) -> None: + inner = _workspace(tmp_path / "inner-home", "p") + outer = _workspace(tmp_path / "outer-home", "p") + repo = tmp_path / "repo" + sub = repo / "sub" + sub.mkdir(parents=True) + (repo / ".oacp").symlink_to(outer) + (sub / ".oacp").symlink_to(inner) + assert resolve_home(env={}, cwd=sub).path == (tmp_path / "inner-home").resolve() + assert resolve_home(env={}, cwd=repo).path == (tmp_path / "outer-home").resolve() + + +def test_plain_workspace_file_in_place(tmp_path: Path) -> None: + workspace = _workspace(tmp_path / "home", "demo") + found = resolve_home(env={}, cwd=workspace.parent) + assert found.path == (tmp_path / "home").resolve() + assert found.source == f"marker:{workspace}" + + +def test_workspace_file_outside_projects_shape_is_ignored(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "workspace.json").write_text("{}", encoding="utf-8") # an editor's file, not a marker + assert find_workspace_marker(repo) is None + assert resolve_home(env={}, cwd=repo).source == "default" + + +def test_dangling_and_foreign_symlinks_are_ignored(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "dangling").symlink_to(tmp_path / "missing" / "projects" / "p" / "workspace.json") + (repo / "elsewhere").symlink_to(tmp_path) + (repo / "not-a-marker").symlink_to(tmp_path / "repo") + assert find_workspace_marker(repo) is None + + +def test_marker_names_the_project(tmp_path: Path) -> None: + workspace = _workspace(tmp_path / "home", "demo") + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".oacp").symlink_to(workspace) + assert resolve_home(env={}, cwd=repo).project == "demo" + assert resolve_home(env={}, cwd=workspace.parent).project == "demo" + + +not_root = pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") + + +@not_root +@pytest.mark.parametrize("ancestor_binding", [True, False], ids=["with-ancestor-binding", "no-ancestor-binding"]) +def test_an_ancestor_that_cannot_be_inspected_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ancestor_binding: bool +) -> None: + """A directory the process cannot inspect might hold a binding; the walk never passes it.""" + monkeypatch.setenv("HOME", str(tmp_path)) + if ancestor_binding: + _binding(tmp_path, schema_version=1, home=str(tmp_path / "outer-store")) + locked = tmp_path / "locked" + repo = locked / "repo" + repo.mkdir(parents=True) + locked.chmod(0) + if os.access(repo, os.R_OK): + locked.chmod(0o700) + pytest.skip("this user traverses directories regardless of their mode bits") + try: + with pytest.raises(HomeError, match="cannot inspect the binding slot"): + resolve_home(env={}, cwd=repo) + finally: + locked.chmod(0o700) diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..01d963f --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Dict + +import pytest + +from agent_memory import layout + +GOLDEN = Path(__file__).resolve().parent / "golden" / "canonical_memory_gitignore.txt" + + +def _digest(root: Path) -> Dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else "dir" + for path in sorted(root.rglob("*")) + } + + +def test_gitignore_text_matches_the_golden_bytes() -> None: + # tests/golden/canonical_memory_gitignore.txt is a byte copy of the 0.4.5 kernel's canonical text. + assert layout.gitignore_text().encode("utf-8") == GOLDEN.read_bytes() + + +def test_gitignore_denies_keystore_last() -> None: + lines = layout.gitignore_text().splitlines() + assert "keys/" in lines + # The deny must come after every allowlist line so it wins for keys/ + # even if a future edit widens the allowlist above it. + assert lines.index("keys/") > max(index for index, line in enumerate(lines) if line.startswith("!")) + + +def test_marker_and_gitignore_names_are_the_fleet_contract() -> None: + assert layout.MARKER_FILE == ".oacp-memory-repo" + assert layout.GITIGNORE_FILE == ".gitignore" + + +@pytest.mark.parametrize( + "path", + [ + ".gitignore", + ".oacp-memory-repo", + "org-memory/recent.md", + "org-memory/debriefs/.gitkeep", + "org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md", + "projects/demo/memory/project_facts.md", + "projects/demo/memory/archive/20260101T000000Z_open_threads.md", + "projects/demo/memory/keys.md", + ], +) +def test_allowed_memory_paths(path: str) -> None: + assert layout.is_allowed_memory_path(path), path + + +@pytest.mark.parametrize( + "path", + [ + "keys/", + "keys/00000000-0000-4000-8000-000000000000/claude/00000000-0000-4000-8000-000000000001/kid.json", + "keys/domain/claude/instance/kid.pub.json", + "keys/.trust_domain", + "org-memory", + "projects/demo/memory", + "projects/demo/memory/.cache", + "projects/demo/memory/.cache/index.json", + "projects/demo/agents/claude/inbox/msg.yaml", + "projects/demo/status.yaml", + "agents/claude/config.yaml", + "README.md", + "state/watch/cursor", + ], +) +def test_denied_memory_paths(path: str) -> None: + assert not layout.is_allowed_memory_path(path), path + + +def test_every_tier_file_and_dir_is_an_allowed_memory_path() -> None: + for tier in layout.TIERS: + root = tier.pattern.replace(layout.WILDCARD, "demo") + for name in tier.files + tier.dirs: + assert layout.is_allowed_memory_path(f"{root}/{name}"), name + for name in tier.unsynced: + assert not layout.is_allowed_memory_path(f"{root}/{name}/x"), name + + +def test_allowed_memory_dirs_lists_existing_tiers_in_allowlist_order(tmp_path: Path) -> None: + (tmp_path / "org-memory").mkdir() + for name in ("zeta", "alpha"): + (tmp_path / "projects" / name / "memory").mkdir(parents=True) + (tmp_path / "projects" / "no-memory").mkdir() + (tmp_path / "projects" / "stray.txt").write_text("", encoding="utf-8") + assert layout.allowed_memory_dirs(tmp_path) == [ + tmp_path / "org-memory", + tmp_path / "projects" / "alpha" / "memory", + tmp_path / "projects" / "zeta" / "memory", + ] + + +def test_allowed_memory_dirs_on_an_empty_or_missing_home(tmp_path: Path) -> None: + assert layout.allowed_memory_dirs(tmp_path) == [] + assert layout.allowed_memory_dirs(tmp_path / "missing") == [] + + +def test_tier_dirs_derive_from_the_table(tmp_path: Path) -> None: + assert layout.org_memory_dir(tmp_path) == tmp_path / "org-memory" + assert layout.project_memory_dir(tmp_path, "demo") == tmp_path / "projects" / "demo" / "memory" + + +@pytest.mark.parametrize("bad", ["", ".hidden", "a/b", "a\\b", "../up"]) +def test_project_names_with_separators_or_leading_dots_are_rejected(tmp_path: Path, bad: str) -> None: + with pytest.raises(ValueError): + layout.project_memory_dir(tmp_path, bad) + + +def test_scaffold_home_creates_the_layout_once(tmp_path: Path) -> None: + home = tmp_path / "home" + created = layout.scaffold_home(home) + assert created[0] == home + assert (home / ".gitignore").read_bytes() == GOLDEN.read_bytes() + assert (home / "org-memory" / "events").is_dir() + assert (home / "org-memory" / "debriefs").is_dir() + assert (home / "projects").is_dir() + assert not (home / layout.MARKER_FILE).exists() # syncing is opt-in, not part of the layout + before = _digest(home) + assert layout.scaffold_home(home) == [] + assert _digest(home) == before + + +def test_scaffold_home_with_a_project_tier(tmp_path: Path) -> None: + home = tmp_path / "home" + created = layout.scaffold_home(home, project="demo") + memory = home / "projects" / "demo" / "memory" + assert memory in created + assert (memory / "archive").is_dir() + assert not any(path.is_file() for path in memory.rglob("*")) # content belongs to the scaffolding verbs + assert layout.scaffold_home(home, project="demo") == [] + other = home / "projects" / "other" / "memory" + assert layout.scaffold_home(home, project="other") == [other, other / "archive"] + + +def _occupy(slot: Path, shape: str, outside: Path) -> None: + if shape == "regular file": + slot.write_bytes(b"custom\n") + elif shape == "directory": + slot.mkdir() + else: + slot.symlink_to(outside) + + +def _assert_untouched(slot: Path, shape: str, outside: Path) -> None: + if shape == "regular file": + assert slot.read_bytes() == b"custom\n" + elif shape == "directory": + assert slot.is_dir() + else: + assert os.readlink(slot) == str(outside) + assert not outside.exists() + + +@pytest.mark.parametrize("shape", ["regular file", "directory", "dangling symlink"]) +def test_scaffold_home_keeps_whatever_occupies_the_gitignore_slot(tmp_path: Path, shape: str) -> None: + """The slot is never rewritten and a link in it is never followed: no bytes land at its target.""" + home = tmp_path / "home" + home.mkdir() + slot, outside = home / ".gitignore", tmp_path / "outside" + _occupy(slot, shape, outside) + created = layout.scaffold_home(home) + assert slot not in created + _assert_untouched(slot, shape, outside) + + +@pytest.mark.parametrize("shape", ["regular file", "dangling symlink"]) +def test_scaffold_home_keeps_a_gitignore_that_appears_after_the_probe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, shape: str +) -> None: + """The existence probe is advisory; the exclusive open is the guarantee.""" + home = tmp_path / "home" + slot, outside = home / ".gitignore", tmp_path / "outside" + real_lexists = os.path.lexists + + def probe_then_lose_the_race(path: object) -> bool: + if Path(path) == slot and not real_lexists(slot): + _occupy(slot, shape, outside) + return False # what the probe saw an instant ago + return real_lexists(path) + + monkeypatch.setattr(os.path, "lexists", probe_then_lose_the_race) + created = layout.scaffold_home(home) + assert slot not in created + _assert_untouched(slot, shape, outside) + + +def test_write_if_absent_creates_a_missing_file_once(tmp_path: Path) -> None: + path = tmp_path / "file" + assert layout.write_if_absent(path, b"first\n") is True + assert layout.write_if_absent(path, b"second\n") is False + assert path.read_bytes() == b"first\n" + with pytest.raises(OSError): + layout.write_if_absent(tmp_path / "missing-dir" / "file", b"") + + +@pytest.mark.parametrize( + "path", + [ + "org-memory/keys/k.json", + "org-memory/debriefs/keys/k.json", + "projects/demo/memory/keys/k.json", + "projects/demo/memory/archive/keys/k.json", + "projects/demo/memory/keys", + "projects/keys/memory/facts.md", + ], +) +def test_a_never_synced_name_is_denied_at_any_depth(path: str) -> None: + # The ignore file can be widened by hand; the predicate is what the sync engine trusts. + assert not layout.is_allowed_memory_path(path), path + + +@pytest.mark.parametrize("path", ["projects/demo/memory/keys.md", "org-memory/keys-rotation.md", "org-memory/my-keys/x.md"]) +def test_names_that_merely_contain_a_never_synced_name_stay_allowed(path: str) -> None: + assert layout.is_allowed_memory_path(path), path diff --git a/tests/test_org.py b/tests/test_org.py new file mode 100644 index 0000000..3698af3 --- /dev/null +++ b/tests/test_org.py @@ -0,0 +1,404 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory init`` and ``org init``: the two-tier layout from bundled templates, and nothing else. + +The grammar: templates load through the public resources API and a missing one +fails loud before any write; a rerun leaves no diff; a binding collision is +refused before any write; no git, no network, no credentials. The installed +wheel proves the template path (``AGENT_MEMORY_TEST_INSTALLED=1``). +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +from importlib import resources +from pathlib import Path +from typing import Dict, Set + +import pytest + +from agent_memory import layout, org +from agent_memory.cli import main +from agent_memory.home import BINDING_FILE, ENV_COMPAT_HOME, ENV_HOME, resolve_home + +CHECKOUT = Path(__file__).resolve().parents[1] +TEMPLATES = CHECKOUT / "src" / "agent_memory" / "templates" +INSTALLED_ENV = "AGENT_MEMORY_TEST_INSTALLED" + +not_root = pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") + +ORG_TREE: Set[str] = { + ".gitignore", + "projects/", + "org-memory/", + "org-memory/recent.md", + "org-memory/decisions.md", + "org-memory/rules.md", + "org-memory/events/", + "org-memory/events/.gitkeep", + "org-memory/debriefs/", + "org-memory/debriefs/.gitkeep", +} + + +def _project_tree(project: str) -> Set[str]: + memory = f"projects/{project}/memory" + return {f"projects/{project}/", f"{memory}/", f"{memory}/archive/"} | {f"{memory}/{name}" for name in layout.PROJECT.files} + + +def _tree(root: Path) -> Dict[str, str]: + """Every entry under ``root``: directories as ``rel/`` mapping to ``dir``, files to their digest.""" + tree: Dict[str, str] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + tree[f"{rel}/"] = "dir" + else: + tree[rel] = hashlib.sha256(path.read_bytes()).hexdigest() + return tree + + +def _template(tier: layout.Tier, name: str) -> bytes: + return (TEMPLATES / org.TEMPLATE_DIRS[tier.name] / name).read_bytes() + + +@pytest.fixture +def no_git(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """No git on PATH, and any subprocess is a test failure: the scaffold is filesystem only.""" + empty = tmp_path / "empty-bin" + empty.mkdir() + monkeypatch.setenv("PATH", str(empty)) + + def refuse(*args: object, **kwargs: object) -> None: + raise AssertionError(f"a subprocess was started: {args[0] if args else kwargs}") + + monkeypatch.setattr(subprocess, "run", refuse) + monkeypatch.setattr(subprocess, "Popen", refuse) + + +# --- the layout --------------------------------------------------------------- + + +def test_a_fresh_home_is_the_full_two_tier_layout(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + report = org.init(home, project="demo") + assert set(_tree(home)) == ORG_TREE | _project_tree("demo") + for name in layout.ORG.files: + assert (layout.org_memory_dir(home) / name).read_bytes() == _template(layout.ORG, name) + for name in layout.PROJECT.files: + assert (layout.project_memory_dir(home, "demo") / name).read_bytes() == _template(layout.PROJECT, name) + # The bug this fixes: an installed 0.4.5 wrote a nine-byte heading-only recent.md from a silent fallback. + assert (layout.org_memory_dir(home) / "recent.md").stat().st_size == len(_template(layout.ORG, "recent.md")) > 100 + assert (home / ".gitignore").read_bytes() == layout.gitignore_text().encode("utf-8") + assert not (home / ".git").exists() + assert set(report.created) == (ORG_TREE | _project_tree("demo")) - {"projects/demo/"} + assert report.kept == () and report.binding is None and report.project == "demo" + assert report.lines()[0] == f"Initialized memory home: {home}" + + +def test_init_without_a_project_creates_the_org_tier_only(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + report = org.init(home) + assert set(_tree(home)) == ORG_TREE + assert report.project is None + + +def test_a_rerun_changes_no_byte_and_reports_what_it_kept(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + org.init(home, project="demo") + before = _tree(home) + report = org.init(home, project="demo") + assert _tree(home) == before + assert report.created == () + # The layout (directories, .gitignore) is silent when present; the tier files are what a rerun reports. + assert set(report.kept) == {entry for entry in ORG_TREE | _project_tree("demo") if not entry.endswith("/")} - {".gitignore"} + assert report.changed is False + assert report.lines()[0] == f"Memory home already complete: {home}" + + +def test_existing_files_are_never_overwritten(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + recent = layout.org_memory_dir(home) / "recent.md" + recent.parent.mkdir(parents=True) + recent.write_text("# mine\n\nhand-written\n", encoding="utf-8") + (home / ".gitignore").write_text("*.swp\n", encoding="utf-8") + report = org.init(home) + assert recent.read_text(encoding="utf-8") == "# mine\n\nhand-written\n" + assert (home / ".gitignore").read_text(encoding="utf-8") == "*.swp\n" + assert "org-memory/recent.md" in report.kept + assert "org-memory/decisions.md" in report.created + + +def test_an_entry_in_a_file_slot_is_kept_not_replaced(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + slot = layout.org_memory_dir(home) / "rules.md" + slot.mkdir(parents=True) + report = org.init(home) + assert slot.is_dir() + assert "org-memory/rules.md/" in report.kept + + +def test_a_home_path_that_is_a_file_is_a_controlled_error(tmp_path: Path, no_git: None) -> None: + occupied = tmp_path / "home" + occupied.write_text("not a directory\n", encoding="utf-8") + with pytest.raises(org.ScaffoldError, match="cannot lay out"): + org.init(occupied) + assert occupied.read_text(encoding="utf-8") == "not a directory\n" + + +def test_a_dangling_symlink_in_a_file_slot_is_kept_and_not_followed(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + org_dir = layout.org_memory_dir(home) + org_dir.mkdir(parents=True) + (org_dir / "recent.md").symlink_to(tmp_path / "outside.md") + report = org.init(home) + assert "org-memory/recent.md" in report.kept + assert not (tmp_path / "outside.md").exists() + + +def test_a_dangling_symlink_in_the_root_gitignore_slot_is_kept_and_not_followed(tmp_path: Path, no_git: None) -> None: + """The root .gitignore slot is a file slot like any other: a follow-the-link probe once wrote the allowlist to its target.""" + home = tmp_path / "home" + home.mkdir() + outside = tmp_path / "outside.gitignore" + (home / ".gitignore").symlink_to(outside) + report = org.init(home) + assert os.readlink(home / ".gitignore") == str(outside) + assert not outside.exists() + assert ".gitignore" not in report.created + assert "org-memory/recent.md" in report.created + + +# --- templates ---------------------------------------------------------------- + + +def _templates_copy(tmp_path: Path) -> Path: + copy = tmp_path / "templates-copy" + shutil.copytree(TEMPLATES, copy) + return copy + + +def test_every_template_is_read_before_the_first_write(tmp_path: Path, no_git: None, monkeypatch: pytest.MonkeyPatch) -> None: + copy = _templates_copy(tmp_path) + (copy / "project-memory" / "known_debt.md").unlink() + monkeypatch.setattr(org, "_templates_root", lambda: copy) + home = tmp_path / "home" + with pytest.raises(org.ScaffoldError, match="templates/project-memory/known_debt.md is missing from the installed package"): + org.init(home) # the project tier was not even requested + assert not home.exists() + + +@not_root +def test_an_unreadable_template_fails_loud(tmp_path: Path, no_git: None, monkeypatch: pytest.MonkeyPatch) -> None: + copy = _templates_copy(tmp_path) + blocked = copy / "org-memory" / "decisions.md" + os.chmod(blocked, 0o000) + monkeypatch.setattr(org, "_templates_root", lambda: copy) + home = tmp_path / "home" + try: + with pytest.raises(org.ScaffoldError, match="templates/org-memory/decisions.md cannot be read"): + org.init(home) + finally: + os.chmod(blocked, 0o644) + assert not home.exists() + + +def test_templates_come_from_the_package_through_the_resources_api() -> None: + root = org._templates_root() + assert root == resources.files("agent_memory") / org.TEMPLATES_DIR + for tier in layout.TIERS: + for name in tier.files: + assert org.template_bytes(tier, name) == _template(tier, name) + assert len(org.template_bytes(tier, name)) > 20 + + +def test_templates_load_from_the_installed_wheel(tmp_path: Path) -> None: + if os.environ.get(INSTALLED_ENV) != "1": + pytest.skip(f"{INSTALLED_ENV}=1 not set: source-checkout run") + location = Path(str(resources.files("agent_memory"))).resolve() + assert "site-packages" in location.parts and CHECKOUT not in location.parents + for tier in layout.TIERS: + for name in tier.files: + assert org.template_bytes(tier, name) == _template(tier, name) + exe = shutil.which("agent-memory") + assert exe, "agent-memory console script not on PATH" + home = tmp_path / "home" + env = {key: value for key, value in os.environ.items() if key not in (ENV_HOME, ENV_COMPAT_HOME)} + result = subprocess.run([exe, "init", "--home", str(home), "--project", "demo"], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + assert (layout.org_memory_dir(home) / "recent.md").read_bytes() == _template(layout.ORG, "recent.md") + assert (layout.project_memory_dir(home, "demo") / "known_debt.md").read_bytes() == _template(layout.PROJECT, "known_debt.md") + + +# --- the binding -------------------------------------------------------------- + + +def test_a_binding_is_recorded_last_and_the_resolver_finds_it(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + repo.mkdir() + report = org.init(home, project="demo", repo=repo) + binding = repo / BINDING_FILE + assert report.binding == binding and report.binding_action == "created" + assert json.loads(binding.read_text(encoding="utf-8")) == {"schema_version": 1, "project": "demo", "home": str(home)} + found = resolve_home(env={}, cwd=repo / "src" / "deep") + assert (found.path, found.project, found.source) == (home, "demo", f"binding:{binding}") + assert any(line.startswith(f"binding recorded: {binding} -> {home}") for line in report.lines()) + assert any("machine-local" in line for line in report.lines()) + + +def test_the_project_derives_from_the_repository_name(tmp_path: Path, no_git: None) -> None: + repo = tmp_path / "my-service" + repo.mkdir() + report = org.init(tmp_path / "home", repo=repo) + assert report.project == "my-service" + assert (layout.project_memory_dir(tmp_path / "home", "my-service") / "project_facts.md").is_file() + + +def test_an_identical_binding_is_left_alone(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + repo.mkdir() + org.init(home, project="demo", repo=repo) + binding = repo / BINDING_FILE + before = binding.read_bytes() + report = org.init(home, project="demo", repo=repo) + assert report.binding_action == "unchanged" and report.changed is False + assert binding.read_bytes() == before + assert any(line.startswith("binding already recorded:") for line in report.lines()) + + +@pytest.mark.parametrize("difference", ["home", "project"]) +def test_a_binding_pointing_elsewhere_is_refused_before_any_write(tmp_path: Path, no_git: None, difference: str) -> None: + repo = tmp_path / "repo" + repo.mkdir() + other = {"schema_version": 1, "project": "demo" if difference == "home" else "other", "home": str(tmp_path / ("elsewhere" if difference == "home" else "home"))} + binding = repo / BINDING_FILE + binding.write_text(json.dumps(other), encoding="utf-8") + before = binding.read_bytes() + home = tmp_path / "home" + with pytest.raises(org.ScaffoldError, match="collision: .* already binds this repository"): + org.init(home, project="demo", repo=repo) + assert not home.exists() + assert binding.read_bytes() == before + + +@pytest.mark.parametrize("shape", ["directory", "malformed", "dangling symlink"]) +def test_an_unusable_binding_entry_is_a_collision(tmp_path: Path, no_git: None, shape: str) -> None: + repo = tmp_path / "repo" + repo.mkdir() + entry = repo / BINDING_FILE + if shape == "directory": + entry.mkdir() + elif shape == "malformed": + entry.write_text("{", encoding="utf-8") + else: + entry.symlink_to(repo / "missing.json") + home = tmp_path / "home" + with pytest.raises(org.ScaffoldError, match="collision: .*; not overwriting"): + org.init(home, project="demo", repo=repo) + assert not home.exists() + assert os.path.lexists(entry) + + +def test_the_repository_must_be_a_directory(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + with pytest.raises(org.ScaffoldError, match="is not a directory"): + org.init(home, project="demo", repo=tmp_path / "nope") + assert not home.exists() + + +@pytest.mark.parametrize("project", ["", ".hidden", "a/b", "a\\b"]) +def test_an_invalid_project_name_is_refused_before_any_write(tmp_path: Path, no_git: None, project: str) -> None: + home = tmp_path / "home" + with pytest.raises(org.ScaffoldError, match="project"): + org.init(home, project=project) + assert not home.exists() + + +def test_an_underivable_project_name_asks_for_the_flag(tmp_path: Path, no_git: None) -> None: + repo = tmp_path / ".dotrepo" + repo.mkdir() + with pytest.raises(org.ScaffoldError, match="pass --project"): + org.init(tmp_path / "home", repo=repo) + assert not (tmp_path / "home").exists() + + +# --- org init ------------------------------------------------------------------- + + +def test_org_init_requires_an_existing_home(tmp_path: Path, no_git: None) -> None: + with pytest.raises(org.ScaffoldError, match="is not a directory"): + org.org_init(tmp_path / "home") + assert not (tmp_path / "home").exists() + + +def test_org_init_completes_the_org_tier_and_touches_no_project(tmp_path: Path, no_git: None) -> None: + home = tmp_path / "home" + layout.scaffold_home(home, project="demo") # directories only, no tier files + report = org.org_init(home) + assert set(report.created) == {"org-memory/recent.md", "org-memory/decisions.md", "org-memory/rules.md", "org-memory/events/.gitkeep", "org-memory/debriefs/.gitkeep"} + assert not any((layout.project_memory_dir(home, "demo") / name).exists() for name in layout.PROJECT.files) + assert report.project is None + + +# --- the command line ----------------------------------------------------------- + + +@pytest.fixture +def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + monkeypatch.delenv(ENV_HOME, raising=False) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_cli_init_reports_and_exits_zero(isolated: Path, no_git: None, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + repo = isolated / "repo" + repo.mkdir() + assert main(["init", "--home", str(home), "--project", "demo", "--repo", str(repo)]) == 0 + out = capsys.readouterr().out.splitlines() + assert out[0] == f"Initialized memory home: {home}" + assert " + org-memory/recent.md" in out + assert "project: demo" in out + assert any(line.startswith(f"binding recorded: {repo / BINDING_FILE}") for line in out) + + assert main(["init", "--home", str(home), "--project", "demo", "--repo", str(repo)]) == 0 + out = capsys.readouterr().out.splitlines() + assert out[0] == f"Memory home already complete: {home}" + assert " (exists) org-memory/recent.md" in out + + +def test_cli_init_uses_the_default_home_when_nothing_names_one( + isolated: Path, no_git: None, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("HOME", str(isolated)) + assert main(["init"]) == 0 + assert capsys.readouterr().out.splitlines()[0] == f"Initialized memory home: {isolated / 'agent-memory'}" + assert set(_tree(isolated / "agent-memory")) == ORG_TREE + + +def test_cli_org_init(isolated: Path, no_git: None, capsys: pytest.CaptureFixture[str]) -> None: + home = isolated / "home" + assert main(["org", "init", "--home", str(home)]) == 1 + assert "agent-memory: error:" in capsys.readouterr().err + home.mkdir() + assert main(["org", "init", "--home", str(home)]) == 0 + assert " + org-memory/rules.md" in capsys.readouterr().out + assert set(_tree(home)) == ORG_TREE + + +def test_cli_collision_is_a_controlled_error(isolated: Path, no_git: None, capsys: pytest.CaptureFixture[str]) -> None: + repo = isolated / "repo" + repo.mkdir() + (repo / BINDING_FILE).write_text(json.dumps({"schema_version": 1, "home": str(isolated / "other")}), encoding="utf-8") + assert main(["init", "--home", str(isolated / "home"), "--project", "demo", "--repo", str(repo)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.startswith("agent-memory: error: collision:") + assert not (isolated / "home").exists() diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..3d04aa0 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,852 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory setup ``: the hook installation grammar, each rule pinned. + +The installation matrix (fresh, idempotent, edited managed file, symlinked +target), the fleet settings fixture that carries the legacy push entry, the +generated script run without the tool and against an unreachable remote, +the codex entry beside the kernel's, and the rule that no push hook is +ever installed. +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +import os +import shlex +import shutil +import subprocess +import stat +import sys +from pathlib import Path +from typing import Dict, List + +import pytest + +from agent_memory import __version__, layout, org +from agent_memory.cli import main +from agent_memory.setup import ( + SPECS, + Result, + RuntimeSpec, + SetupError, + legacy, + run_setup, + script_text, + template_digest, + workflow_digest, + workflow_text, +) +from agent_memory.setup.common import ( + CHMOD_SCRIPT, + CREATE_SETTINGS, + KEEP_FLAG, + KEEP_LEGACY_FILE, + REGENERATE_SCRIPT, + REGISTER, + REGISTERED, + REGISTRATION_HELD, + REMOVE_LEGACY_FILE, + RETIRE_REGISTRATION, + SCRIPT_CONFLICT, + SCRIPT_IN_PLACE, + SETTINGS_CONFLICT, + STRIP_FLAG, + WORKFLOW_CONFLICT, + WORKFLOW_IN_PLACE, + REGENERATE_WORKFLOW, + WRITE_SCRIPT, + WRITE_WORKFLOW, +) + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "setup" +CLAUDE = SPECS["claude"] +CODEX = SPECS["codex"] + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + home = tmp_path / "home" + org.init(home, project="demo") + return home + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + return repo + + +@pytest.fixture(params=sorted(SPECS), ids=sorted(SPECS)) +def spec(request: pytest.FixtureRequest) -> RuntimeSpec: + return SPECS[request.param] + + +def _tree(root: Path) -> Dict[str, str]: + """Every entry under ``root``: symlinks by target, directories as ``rel/``, files by digest and mode.""" + tree: Dict[str, str] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_symlink(): + tree[rel] = f"link:{os.readlink(path)}" + elif path.is_dir(): + tree[f"{rel}/"] = "dir" + else: + tree[rel] = f"{_sha(path.read_bytes())}:{path.stat().st_mode & 0o777:o}" + return tree + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _commands(settings: dict, event: str) -> List[str]: + return [hook["command"] for entry in settings.get("hooks", {}).get(event, []) for hook in entry["hooks"]] + + +def _actions(result: Result) -> List[str]: + return [step.action for step in result.plan.steps] + + +def _place(repo: Path, rel: str, text: str) -> Path: + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + return path + + +def _bash() -> str: + found = shutil.which("bash") + if found is None: + pytest.skip("bash is not available") + return found + + +def _tool_on_path(tmp_path: Path) -> Path: + """A PATH directory whose ``agent-memory`` runs this interpreter's package.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + shim = bin_dir / "agent-memory" + shim.write_text(f'#!/bin/sh\nexec {shlex.quote(sys.executable)} -m agent_memory "$@"\n', encoding="utf-8") + shim.chmod(0o755) + return bin_dir + + +def _run_hook(spec: RuntimeSpec, repo: Path, env: Dict[str, str]) -> subprocess.CompletedProcess: + return subprocess.run([_bash(), spec.script_file], cwd=str(repo), env=env, capture_output=True, text=True, check=False) + + +# --- the installation matrix --------------------------------------------------- + + +def test_fresh_install(spec: RuntimeSpec, repo: Path, home: Path) -> None: + result = run_setup(spec, repo, home) + assert not result.plan.conflicts + assert _actions(result) == [WRITE_SCRIPT, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + script = repo / spec.script_file + assert script.read_text(encoding="utf-8") == script_text(spec) + assert script.stat().st_mode & 0o777 == 0o755 + settings = _load(repo / spec.settings_file) + for key, value in spec.fresh_settings.items(): + assert settings[key] == value + assert settings["hooks"][spec.event] == [spec.entry()] + assert result.plan.receipt_path.parent == home / layout.SETUP_DIR / spec.name + receipt = _load(result.plan.receipt_path) + assert receipt["managed"][0]["digest"] == template_digest(spec) == _sha(script.read_bytes()) + wrapper = repo / spec.workflow_file + assert wrapper.read_text(encoding="utf-8") == workflow_text(spec) + assert receipt["managed"][2]["digest"] == workflow_digest(spec) == _sha(wrapper.read_bytes()) + + +def test_no_push_hook_is_ever_installed(spec: RuntimeSpec, repo: Path, home: Path) -> None: + assert "push" not in script_text(spec) + assert f"startup --runtime {spec.name} --pull" in script_text(spec) + run_setup(spec, repo, home) + settings = _load(repo / spec.settings_file) + assert "SessionEnd" not in settings["hooks"] + assert [path for path in _tree(repo) if "push" in path] == [] + + +def test_rerun_changes_no_byte(spec: RuntimeSpec, repo: Path, home: Path) -> None: + run_setup(spec, repo, home) + before = (_tree(repo), _tree(home)) + again = run_setup(spec, repo, home) + assert not again.plan.changed + assert _actions(again) == [SCRIPT_IN_PLACE, REGISTERED, WORKFLOW_IN_PLACE] + assert (_tree(repo), _tree(home)) == before + + +def test_dry_run_writes_nothing_and_plans_the_same_steps(spec: RuntimeSpec, repo: Path, home: Path) -> None: + home_before = _tree(home) + dry = run_setup(spec, repo, home, dry_run=True) + assert dry.plan.changed and dry.plan.receipt_write + assert _tree(repo) == {".git/": "dir"} + assert _tree(home) == home_before + real = run_setup(spec, repo, home) + assert [(step.action, step.path) for step in dry.plan.steps] == [(step.action, step.path) for step in real.plan.steps] + + +def test_edited_script_is_a_named_conflict_and_kept(spec: RuntimeSpec, repo: Path, home: Path) -> None: + run_setup(spec, repo, home) + script = repo / spec.script_file + edited = script_text(spec) + "echo mine\n" + script.write_text(edited, encoding="utf-8") + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_CONFLICT, REGISTERED, WORKFLOW_IN_PLACE] + assert [step.path for step in result.plan.conflicts] == [spec.script_file] + assert "edited" in result.plan.conflicts[0].detail + assert script.read_text(encoding="utf-8") == edited + receipt = _load(result.plan.receipt_path) + assert receipt["managed"][0]["state"] == "conflict" + assert receipt["managed"][0]["digest"] == _sha(edited.encode("utf-8")) + assert receipt["conflicts"] == [{"path": spec.script_file, "detail": result.plan.conflicts[0].detail}] + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(home)]) == 3 + + +def test_an_earlier_template_is_regenerated(spec: RuntimeSpec, repo: Path, home: Path) -> None: + old = "#!/usr/bin/env bash\n# an earlier shipped template\n" + _place(repo, spec.script_file, old) + older = dataclasses.replace(spec, previous_template_digests=(_sha(old.encode("utf-8")),)) + result = run_setup(older, repo, home) + assert _actions(result) == [REGENERATE_SCRIPT, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + script = repo / spec.script_file + assert script.read_text(encoding="utf-8") == script_text(spec) + assert script.stat().st_mode & 0o777 == 0o755 + + +def test_symlinked_script_is_never_written_through(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + shared = tmp_path / "shared" / "hook.sh" + shared.parent.mkdir() + shared.write_text(script_text(spec), encoding="utf-8") + shared.chmod(0o755) + script = repo / spec.script_file + script.parent.mkdir(parents=True) + script.symlink_to(shared) + + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + assert "symlink" in result.plan.steps[0].detail + receipt = _load(result.plan.receipt_path) + assert receipt["managed"][0]["symlink"] is True + assert receipt["managed"][0]["resolved"] == os.path.realpath(shared) + + shared.write_text("# somebody else's hook\n", encoding="utf-8") + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_CONFLICT, REGISTERED, WORKFLOW_IN_PLACE] + assert os.path.realpath(shared) in result.plan.conflicts[0].detail + assert shared.read_text(encoding="utf-8") == "# somebody else's hook\n" + assert script.is_symlink() + + +def test_dangling_script_symlink_holds_the_registration(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + script = repo / spec.script_file + script.parent.mkdir(parents=True) + script.symlink_to(tmp_path / "missing.sh") + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_CONFLICT, CREATE_SETTINGS, REGISTRATION_HELD, WRITE_WORKFLOW] + assert not (tmp_path / "missing.sh").exists() + assert _commands(_load(repo / spec.settings_file), spec.event) == [] + assert _load(result.plan.receipt_path)["managed"][1]["state"] == "held" + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(home)]) == 3 + + +def test_symlinked_hooks_directory_is_protected(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + shared_dir = tmp_path / "shared-hooks" + shared_dir.mkdir() + hooks_dir = (repo / spec.script_file).parent + hooks_dir.parent.mkdir(parents=True, exist_ok=True) + hooks_dir.symlink_to(shared_dir) + result = run_setup(spec, repo, home) + assert result.plan.steps[0].action == SCRIPT_CONFLICT + assert os.path.realpath(shared_dir) in result.plan.steps[0].detail + assert list(shared_dir.iterdir()) == [] + assert REGISTRATION_HELD in _actions(result) + + +def test_symlinked_settings_file_is_not_edited(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + shared = tmp_path / "shared-settings.json" + shared.write_text("{}\n", encoding="utf-8") + settings = repo / spec.settings_file + settings.parent.mkdir(parents=True) + settings.symlink_to(shared) + result = run_setup(spec, repo, home) + assert _actions(result) == [WRITE_SCRIPT, SETTINGS_CONFLICT, WRITE_WORKFLOW] + assert os.path.realpath(shared) in result.plan.steps[1].detail + assert shared.read_text(encoding="utf-8") == "{}\n" + assert (repo / spec.script_file).is_file() + receipt = _load(result.plan.receipt_path) + assert receipt["managed"][1]["symlink"] is True and receipt["managed"][1]["state"] == "conflict" + assert receipt["managed"][0]["state"] == "installed" + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(home)]) == 3 + + +@pytest.mark.parametrize("content", ["{", "[]", '{"hooks": "x"}', '{"hooks": {"SessionStart": "x"}}']) +def test_unusable_settings_file_is_a_conflict_not_a_crash(spec: RuntimeSpec, repo: Path, home: Path, content: str) -> None: + settings = _place(repo, spec.settings_file, content) + result = run_setup(spec, repo, home) + assert _actions(result) == [WRITE_SCRIPT, SETTINGS_CONFLICT, WRITE_WORKFLOW] + assert settings.read_text(encoding="utf-8") == content + + +def test_missing_home_is_an_error_naming_init(spec: RuntimeSpec, repo: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SetupError, match="agent-memory init"): + run_setup(spec, repo, tmp_path / "nope") + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(tmp_path / "nope")]) == 1 + assert "agent-memory init" in capsys.readouterr().err + assert _tree(repo) == {".git/": "dir"} + + +def test_missing_repo_is_an_error(spec: RuntimeSpec, home: Path, tmp_path: Path) -> None: + with pytest.raises(SetupError, match="not a directory"): + run_setup(spec, tmp_path / "nope", home) + + +# --- resuming and never duplicating --------------------------------------------- + + +def test_resumes_after_an_interruption(spec: RuntimeSpec, repo: Path, home: Path) -> None: + _place(repo, spec.script_file, script_text(spec)) # interrupted after the script was written + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + result.plan.receipt_path.unlink() # the receipt lost: rewritten, the repository untouched + before = _tree(repo) + again = run_setup(spec, repo, home) + assert again.plan.receipt_write and again.plan.receipt_path.is_file() + assert _actions(again) == [SCRIPT_IN_PLACE, REGISTERED, WORKFLOW_IN_PLACE] + assert _tree(repo) == before + + +def test_registration_without_a_script_gets_the_script(spec: RuntimeSpec, repo: Path, home: Path) -> None: + settings = _place(repo, spec.settings_file, json.dumps({"hooks": {spec.event: [spec.entry()]}})) + result = run_setup(spec, repo, home) + assert _actions(result) == [WRITE_SCRIPT, REGISTERED, WRITE_WORKFLOW] + assert _load(settings)["hooks"][spec.event] == [spec.entry()] + + +def test_registration_inside_a_custom_entry_is_not_duplicated_or_edited(spec: RuntimeSpec, repo: Path, home: Path) -> None: + custom = { + "matcher": "startup|resume", + "hooks": [ + {"type": "command", "command": "./mine.sh", "timeout": 5}, + {"type": "command", "command": spec.script_file, "timeout": 99}, + ], + } + original = json.dumps({"hooks": {spec.event: [custom]}}, indent=4) + settings = _place(repo, spec.settings_file, original) + result = run_setup(spec, repo, home) + assert _actions(result) == [WRITE_SCRIPT, REGISTERED, WRITE_WORKFLOW] + assert settings.read_text(encoding="utf-8") == original + + +# --- the fleet fixture: legacy hooks retired, custom entries kept --------------- + + +def _fleet_repo(repo: Path) -> Path: + settings = repo / CLAUDE.settings_file + settings.parent.mkdir(parents=True) + shutil.copy(FIXTURES / "claude_settings_fleet.json", settings) + _place(repo, legacy.CLAUDE_LEGACY_PULL_COMMAND, legacy.CLAUDE_LEGACY_PULL_SCRIPT) + _place(repo, legacy.CLAUDE_LEGACY_PUSH_COMMAND, legacy.CLAUDE_LEGACY_PUSH_SCRIPT) + return settings + + +def test_legacy_digests_pin_the_fleet_templates() -> None: + # Measured on the fleet's eleven pull and seven push hook files, 2026-09-06: one digest each. + assert legacy.CLAUDE_LEGACY_FILES == { + ".claude/hooks/oacp-memory-pull.sh": ("3284e8c17f644bc166fbe3e5a617ec66ecf2028ed58b8f2eee6aaf11357c79f3",), + ".claude/hooks/oacp-memory-push.sh": ("bb2c02b7f529b18e9aacbe1162a9082b36a713dd1b09e4ebdc6223ca9ab863cc",), + } + + +def test_fleet_settings_retire_the_legacy_hooks_and_keep_custom_entries(repo: Path, home: Path) -> None: + settings = _fleet_repo(repo) + fixture = _load(FIXTURES / "claude_settings_fleet.json") + result = run_setup(CLAUDE, repo, home) + assert not result.plan.conflicts + assert _actions(result) == [ + WRITE_SCRIPT, + RETIRE_REGISTRATION, + RETIRE_REGISTRATION, + REGISTER, + REMOVE_LEGACY_FILE, + REMOVE_LEGACY_FILE, + WRITE_WORKFLOW, + ] + after = _load(settings) + assert "SessionEnd" not in after["hooks"] + assert after["hooks"]["PreToolUse"] == fixture["hooks"]["PreToolUse"] + assert after["$schema"] == fixture["$schema"] + assert _commands(after, "SessionStart") == [CLAUDE.script_file] + assert not (repo / legacy.CLAUDE_LEGACY_PULL_COMMAND).exists() + assert not (repo / legacy.CLAUDE_LEGACY_PUSH_COMMAND).exists() + receipt = _load(result.plan.receipt_path) + assert [(item["kind"], item.get("removed")) for item in receipt["retired"]] == [ + ("registration", None), + ("registration", None), + ("file", True), + ("file", True), + ] + again = run_setup(CLAUDE, repo, home) + assert not again.plan.changed + assert _load(again.plan.receipt_path)["retired"] == receipt["retired"] # the history stays recorded + + +def test_edited_legacy_script_is_kept_while_its_registration_is_retired(repo: Path, home: Path) -> None: + settings = _fleet_repo(repo) + push = repo / legacy.CLAUDE_LEGACY_PUSH_COMMAND + push.write_text(legacy.CLAUDE_LEGACY_PUSH_SCRIPT + "echo custom\n", encoding="utf-8") + result = run_setup(CLAUDE, repo, home) + assert not result.plan.conflicts + kept = [step for step in result.plan.steps if step.action == KEEP_LEGACY_FILE] + assert [step.path for step in kept] == [legacy.CLAUDE_LEGACY_PUSH_COMMAND] + assert "edited" in kept[0].detail + assert "custom" in push.read_text(encoding="utf-8") + assert "SessionEnd" not in _load(settings)["hooks"] + assert not (repo / legacy.CLAUDE_LEGACY_PULL_COMMAND).exists() + + +# The legacy-file preservation matrix: every uncertainty keeps the file. Each row plants +# the shipped legacy pull template and one doubt; ``detail`` is the reason the report names. +PULL = legacy.CLAUDE_LEGACY_PULL_COMMAND +PULL_SCRIPT = legacy.CLAUDE_LEGACY_PULL_SCRIPT +PRESERVATION_ROWS = { + "leaf_symlink": "symlink", + "linked_hooks_dir": ".claude/hooks is a symlink", + "settings_symlink": "could not be read in full", + "settings_not_json": "could not be read in full", + "settings_not_an_object": "could not be read in full", + "custom_wrapper": "still named by `bash .claude/hooks/oacp-memory-pull.sh`", + "custom_wrapper_by_basename": "still named by `cd .claude/hooks && ./oacp-memory-pull.sh`", + "custom_wrapper_other_event": "still named by `bash .claude/hooks/oacp-memory-pull.sh`", + "edited": "edited, kept", +} + + +def _plant(repo: Path, row: str, tmp_path: Path) -> Path: + """The repository of one matrix row; returns the legacy file's path (through any link).""" + settings = repo / CLAUDE.settings_file + hooks_dir = repo / ".claude" / "hooks" + entry = {"matcher": "startup", "hooks": [{"type": "command", "command": PULL}]} + data: object = {"hooks": {"SessionStart": [entry]}} + if row == "linked_hooks_dir": + shared = tmp_path / "shared-hooks" + shared.mkdir() + hooks_dir.parent.mkdir(parents=True) + hooks_dir.symlink_to(shared, target_is_directory=True) + else: + hooks_dir.mkdir(parents=True) + legacy_file = repo / PULL + if row == "leaf_symlink": + shared_file = tmp_path / "shared-pull.sh" + shared_file.write_text(PULL_SCRIPT, encoding="utf-8") + legacy_file.symlink_to(shared_file) + else: + legacy_file.write_text(PULL_SCRIPT + ("echo custom\n" if row == "edited" else ""), encoding="utf-8") + legacy_file.chmod(0o755) + if row == "custom_wrapper": + entry["hooks"][0]["command"] = f"bash {PULL}" + elif row == "custom_wrapper_by_basename": + entry["hooks"][0]["command"] = "cd .claude/hooks && ./oacp-memory-pull.sh" + elif row == "custom_wrapper_other_event": + data = {"hooks": {"PreCompact": [{"hooks": [{"type": "command", "command": f"bash {PULL}"}]}]}} + text = json.dumps(data, indent=2) + if row == "settings_not_json": + text = "{broken" + elif row == "settings_not_an_object": + text = "[]" + if row == "settings_symlink": + shared_settings = tmp_path / "shared-settings.json" + shared_settings.write_text(text, encoding="utf-8") + settings.symlink_to(shared_settings) + else: + settings.write_text(text, encoding="utf-8") + return legacy_file + + +@pytest.mark.parametrize("row", sorted(PRESERVATION_ROWS)) +def test_legacy_file_preservation_matrix(repo: Path, home: Path, tmp_path: Path, row: str) -> None: + legacy_file = _plant(repo, row, tmp_path) + settings = repo / CLAUDE.settings_file + settings_before = settings.read_bytes() + dry = run_setup(CLAUDE, repo, home, dry_run=True) + result = run_setup(CLAUDE, repo, home) + assert _actions(dry) == _actions(result) + kept = [step for step in result.plan.steps if step.action == KEEP_LEGACY_FILE] + assert [step.path for step in kept] == [PULL], _actions(result) + assert PRESERVATION_ROWS[row] in kept[0].detail, kept[0].detail + assert REMOVE_LEGACY_FILE not in _actions(result) and not result.plan.removals + assert os.path.lexists(legacy_file) + assert _sha(legacy_file.read_bytes()) == _sha((PULL_SCRIPT + ("echo custom\n" if row == "edited" else "")).encode()) + custom = row.startswith("custom_wrapper") or row.startswith("settings") or row == "linked_hooks_dir" + if custom: + # A command this tool does not recognize, or settings it cannot read in full, are never edited. + assert RETIRE_REGISTRATION not in _actions(result) + if row.startswith("settings") or row == "linked_hooks_dir": + assert settings.read_bytes() == settings_before + if row.startswith("custom_wrapper"): + after = _load(settings) + event = "PreCompact" if row == "custom_wrapper_other_event" else "SessionStart" + assert _commands(after, event)[0] in {f"bash {PULL}", "cd .claude/hooks && ./oacp-memory-pull.sh"} + + +def test_held_registration_leaves_the_legacy_hooks_in_place(repo: Path, home: Path, tmp_path: Path) -> None: + """When the new script cannot be placed, nothing is retired: a repository is never left without a memory hook.""" + _plant(repo, "linked_hooks_dir", tmp_path) + settings = repo / CLAUDE.settings_file + before = settings.read_bytes() + result = run_setup(CLAUDE, repo, home) + assert _actions(result) == [SCRIPT_CONFLICT, REGISTRATION_HELD, KEEP_LEGACY_FILE, WRITE_WORKFLOW] + assert "legacy hooks stay" in result.plan.steps[1].detail + assert settings.read_bytes() == before + assert _commands(_load(settings), "SessionStart") == [PULL] + + +def test_legacy_script_still_registered_elsewhere_is_kept(repo: Path, home: Path) -> None: + settings = _fleet_repo(repo) + data = _load(settings) + data["hooks"]["PreCompact"] = [{"hooks": [{"type": "command", "command": legacy.CLAUDE_LEGACY_PULL_COMMAND}]}] + settings.write_text(json.dumps(data, indent=2), encoding="utf-8") + result = run_setup(CLAUDE, repo, home) + kept = next(step for step in result.plan.steps if step.action == KEEP_LEGACY_FILE) + assert kept.path == legacy.CLAUDE_LEGACY_PULL_COMMAND and "still registered" in kept.detail + assert (repo / legacy.CLAUDE_LEGACY_PULL_COMMAND).is_file() + assert _commands(_load(settings), "PreCompact") == [legacy.CLAUDE_LEGACY_PULL_COMMAND] + assert not (repo / legacy.CLAUDE_LEGACY_PUSH_COMMAND).exists() + + +# --- codex: beside the kernel's entry ------------------------------------------ + + +def test_codex_entry_sits_beside_the_kernel_entry_and_retires_only_its_pull_flag(repo: Path, home: Path) -> None: + hooks_file = repo / CODEX.settings_file + hooks_file.parent.mkdir(parents=True) + shutil.copy(FIXTURES / "codex_hooks_kernel.json", hooks_file) + fixture = _load(FIXTURES / "codex_hooks_kernel.json") + result = run_setup(CODEX, repo, home) + assert not result.plan.conflicts + assert _actions(result) == [WRITE_SCRIPT, STRIP_FLAG, REGISTER, WRITE_WORKFLOW] + after = _load(hooks_file) + assert after["description"] == fixture["description"] + kernel, custom, ours = after["hooks"]["SessionStart"] + kernel_hook = kernel["hooks"][0] + fixture_hook = fixture["hooks"]["SessionStart"][0]["hooks"][0] + assert kernel_hook["command"] == "oacp session-init --hook --project demo --hub-dir /home/user/oacp" + assert kernel_hook["additionalContextLimit"] == 4000 + assert {k: v for k, v in kernel_hook.items() if k != "command"} == {k: v for k, v in fixture_hook.items() if k != "command"} + assert custom == fixture["hooks"]["SessionStart"][1] + assert ours == CODEX.entry() + assert ours["matcher"] == "^startup$" and ours["hooks"][0]["statusMessage"] == "Pulling agent memory" + assert "additionalContextLimit" not in ours["hooks"][0] + again = run_setup(CODEX, repo, home) + assert not again.plan.changed and _actions(again) == [SCRIPT_IN_PLACE, REGISTERED, WORKFLOW_IN_PLACE] + + +def _codex_repo_with(repo: Path, command: str) -> Path: + hooks_file = repo / CODEX.settings_file + hooks_file.parent.mkdir(parents=True) + data = {"hooks": {"SessionStart": [{"matcher": "^startup$", "hooks": [{"type": "command", "command": command}]}]}} + hooks_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + return hooks_file + + +# The flag-retirement grammar: one simple command is edited in place, byte for byte around the flag; +# anything the shell reads as more than that is left exactly as written and named. +FLAG_ROWS = { + "plain": ("oacp session-init --hook --pull-memory --project demo", "oacp session-init --hook --project demo"), + "last": ("oacp session-init --hook --project demo --pull-memory", "oacp session-init --hook --project demo"), + "quoted_value_kept": ('oacp session-init --hook --hub-dir "$HOME/oacp" --pull-memory', 'oacp session-init --hook --hub-dir "$HOME/oacp"'), + "twice": ("oacp session-init --hook --pull-memory --pull-memory", "oacp session-init --hook"), + "and_list": ("oacp session-init --hook --project demo --pull-memory && echo CUSTOM", None), + "or_list": ("oacp session-init --hook --pull-memory || true", None), + "pipe": ("oacp session-init --hook --pull-memory | tee log", None), + "sequence": ("oacp session-init --hook --pull-memory; true", None), + "redirect": ("oacp session-init --hook --pull-memory > /dev/null", None), + "substitution": ("oacp session-init --hook --hub-dir $(pwd) --pull-memory", None), + "backticks": ("oacp session-init --hook --hub-dir `pwd` --pull-memory", None), + "quoted_flag": ("oacp session-init --hook '--pull-memory'", None), + # A newline separates commands as `;` does; the flag on a later line belongs to that line's command. + "newline_separated": ("oacp session-init --hook\nprintf '%s\\n' --pull-memory", None), + "flag_on_both_lines": ("oacp session-init --hook --pull-memory\nprintf '%s\\n' --pull-memory", None), + "crlf_separated": ("oacp session-init --hook --pull-memory\r\necho CUSTOM", None), +} + + +@pytest.mark.parametrize("row", sorted(FLAG_ROWS)) +def test_codex_pull_flag_is_retired_only_from_one_simple_command(repo: Path, home: Path, row: str) -> None: + before, expected = FLAG_ROWS[row] + hooks_file = _codex_repo_with(repo, before) + result = run_setup(CODEX, repo, home) + assert not result.plan.conflicts + kernel_after = _commands(_load(hooks_file), "SessionStart")[0] + if expected is None: + assert _actions(result) == [WRITE_SCRIPT, KEEP_FLAG, REGISTER, WRITE_WORKFLOW] + assert kernel_after == before + assert f"left in `{before}`" in result.plan.steps[1].detail + else: + assert _actions(result) == [WRITE_SCRIPT, STRIP_FLAG, REGISTER, WRITE_WORKFLOW] + assert kernel_after == expected + assert "--pull-memory" not in kernel_after or expected is None + again = run_setup(CODEX, repo, home) + assert not again.plan.changed + + +def test_codex_fresh_hooks_file(repo: Path, home: Path) -> None: + run_setup(CODEX, repo, home) + assert _load(repo / CODEX.settings_file) == { + "description": CODEX.fresh_settings["description"], + "hooks": {"SessionStart": [CODEX.entry()]}, + } + assert CODEX.entry()["hooks"][0]["timeout"] == 60 + + +# --- the execute bit ------------------------------------------------------------- + + +def test_template_without_its_execute_bit_is_repaired_and_runs(spec: RuntimeSpec, repo: Path, home: Path) -> None: + script = _place(repo, spec.script_file, script_text(spec)) + script.chmod(0o644) + dry = run_setup(spec, repo, home, dry_run=True) + assert _actions(dry) == [CHMOD_SCRIPT, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] and script.stat().st_mode & 0o777 == 0o644 + result = run_setup(spec, repo, home) + assert _actions(result) == [CHMOD_SCRIPT, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] and not result.plan.conflicts + assert "0644" in result.plan.steps[0].detail + assert script.stat().st_mode & 0o777 == 0o755 + assert script.read_text(encoding="utf-8") == script_text(spec) + managed = _load(result.plan.receipt_path)["managed"][0] + assert managed["state"] == "installed" and managed["mode"] == "0755" + run = subprocess.run(["/bin/sh", "-c", spec.script_file], cwd=str(repo), capture_output=True, text=True, check=False) + assert run.returncode == 0, run.stderr + again = run_setup(spec, repo, home) + assert not again.plan.changed and _actions(again) == [SCRIPT_IN_PLACE, REGISTERED, WORKFLOW_IN_PLACE] + + +def test_linked_template_without_its_execute_bit_is_held_not_chmodded(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + shared = tmp_path / "shared.sh" + shared.write_text(script_text(spec), encoding="utf-8") + shared.chmod(0o644) + script = repo / spec.script_file + script.parent.mkdir(parents=True) + script.symlink_to(shared) + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_CONFLICT, CREATE_SETTINGS, REGISTRATION_HELD, WRITE_WORKFLOW] + assert "not executable (mode 0644)" in result.plan.steps[0].detail + assert shared.stat().st_mode & 0o777 == 0o644 and script.is_symlink() + assert _commands(_load(repo / spec.settings_file), spec.event) == [] + managed = _load(result.plan.receipt_path)["managed"][0] + assert managed == { + "path": spec.script_file, + "resolved": os.path.realpath(shared), + "symlink": True, + "state": "conflict", + "digest": template_digest(spec), + "template_digest": template_digest(spec), + "mode": "0644", + } + + +def test_owner_executable_template_is_left_at_its_mode(spec: RuntimeSpec, repo: Path, home: Path) -> None: + script = _place(repo, spec.script_file, script_text(spec)) + script.chmod(0o700) + result = run_setup(spec, repo, home) + assert _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + assert script.stat().st_mode & 0o777 == 0o700 + assert _load(result.plan.receipt_path)["managed"][0]["mode"] == "0700" + + +# --- the generated script, run ------------------------------------------------ + + +def test_generated_script_without_the_tool_warns_and_exits_zero(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + run_setup(spec, repo, home) + empty = tmp_path / "empty-path" + empty.mkdir() + completed = _run_hook(spec, repo, {"PATH": str(empty), "HOME": str(tmp_path)}) + assert completed.returncode == 0, completed.stderr + assert "command not found" in completed.stdout + if spec.name == "codex": + payload = json.loads(completed.stdout) + assert payload["continue"] is True + assert "command not found" in payload["hookSpecificOutput"]["additionalContext"] + + +def test_generated_script_warns_and_exits_zero_when_the_remote_is_unreachable( + spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path, git_env: None +) -> None: + run_setup(spec, repo, home) + assert main(["enable", "--home", str(home), "--remote", str(tmp_path / "missing.git")]) == 1 + assert (home / layout.MARKER_FILE).is_file() + env = {**os.environ, "PATH": os.pathsep.join([str(_tool_on_path(tmp_path)), os.environ.get("PATH", "")])} + env["AGENT_MEMORY_HOME"] = str(home) + completed = _run_hook(spec, repo, env) + assert completed.returncode == 0, completed.stderr + assert "Traceback" not in completed.stderr + out = completed.stdout + if spec.name == "codex": + payload = json.loads(out) + assert payload["continue"] is True and "degraded" in payload["systemMessage"] + out = payload["hookSpecificOutput"]["additionalContext"] + assert "memory pull" in out and "stale" in out + + +def test_generated_script_pulls_and_prints_the_manifest(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path, git_env: None) -> None: + run_setup(spec, repo, home) + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "--quiet", str(remote)], check=True) + assert main(["enable", "--home", str(home), "--remote", str(remote)]) == 0 + (repo / ".agent-memory.json").write_text( + json.dumps({"schema_version": 1, "project": "demo", "home": str(home)}), encoding="utf-8" + ) + env = {**os.environ, "PATH": os.pathsep.join([str(_tool_on_path(tmp_path)), os.environ.get("PATH", "")])} + env.pop("AGENT_MEMORY_HOME", None) + env.pop("OACP_HOME", None) + completed = _run_hook(spec, repo, env) + assert completed.returncode == 0, completed.stderr + out = completed.stdout + if spec.name == "codex": + payload = json.loads(out) + assert payload["continue"] is True and "systemMessage" not in payload + out = payload["hookSpecificOutput"]["additionalContext"] + assert "memory pull: already synced." in out + assert "project demo (binding:" in out + assert "1. projects/demo/memory/project_facts.md: readable" in out + assert f"agent-memory startup ({spec.name})" in out + + +# --- the command line and the receipt -------------------------------------------- + + +def test_cli_setup_reports_lines_json_and_dry_run(spec: RuntimeSpec, repo: Path, home: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(home), "--dry-run"]) == 0 + out = capsys.readouterr().out + assert f"+ {spec.script_file}: would write" in out and "dry run: nothing was written." in out + assert _tree(repo) == {".git/": "dir"} + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(home), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["schema_version"] == 1 and payload["action"] == "setup" and payload["runtime"] == spec.name + assert payload["changed"] is True and payload["conflicts"] == [] and payload["receipt"]["written"] is True + assert [step["action"] for step in payload["steps"]] == [WRITE_SCRIPT, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + assert main(["setup", spec.name, "--repo", str(repo), "--home", str(home)]) == 0 + assert "nothing to do" in capsys.readouterr().out + with pytest.raises(SystemExit) as exit_info: + main(["setup", "vim"]) + assert exit_info.value.code == 2 + + +def test_cli_setup_finds_the_repo_from_a_subdirectory(spec: RuntimeSpec, repo: Path, home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + sub = repo / "src" / "pkg" + sub.mkdir(parents=True) + monkeypatch.chdir(sub) + assert main(["setup", spec.name, "--home", str(home)]) == 0 + assert (repo / spec.script_file).is_file() + assert not (sub / spec.script_file).exists() + + +def test_receipt_records_what_was_installed(spec: RuntimeSpec, repo: Path, home: Path) -> None: + result = run_setup(spec, repo, home) + receipt = _load(result.plan.receipt_path) + assert receipt["schema_version"] == 1 and receipt["tool"] == "agent-memory" and receipt["version"] == __version__ + assert receipt["runtime"] == spec.name and receipt["repo"] == str(repo) and receipt["home"] == str(home) + assert receipt["written_at_utc"].endswith("Z") + script, settings, flow = receipt["managed"] + assert script == { + "path": spec.script_file, + "resolved": os.path.realpath(repo / spec.script_file), + "symlink": False, + "state": "installed", + "digest": template_digest(spec), + "template_digest": template_digest(spec), + "mode": "0755", + } + assert settings["path"] == spec.settings_file and settings["state"] == "registered" + assert settings["digest"] == _sha((repo / spec.settings_file).read_bytes()) + assert settings["registration"] == {"event": spec.event, "matcher": spec.matcher, "command": spec.script_file} + assert flow == { + "path": spec.workflow_file, + "resolved": os.path.realpath(repo / spec.workflow_file), + "symlink": False, + "state": "installed", + "digest": workflow_digest(spec), + "template_digest": workflow_digest(spec), + } + assert receipt["retired"] == [] and receipt["conflicts"] == [] + + +def test_setup_receipts_never_sync(tmp_path: Path, git_env: None) -> None: + assert not layout.is_allowed_memory_path(f"{layout.SETUP_DIR}/claude/abc.json") + home = tmp_path / "home" + layout.scaffold_home(home) + receipt = home / layout.SETUP_DIR / "claude" / "abc.json" + receipt.parent.mkdir(parents=True) + receipt.write_text("{}\n", encoding="utf-8") + subprocess.run(["git", "init", "--quiet"], cwd=str(home), check=True) + ignored = subprocess.run(["git", "check-ignore", "-q", f"{layout.SETUP_DIR}/claude/abc.json"], cwd=str(home), check=False) + assert ignored.returncode == 0 + + +# --- the workflow file beside the hook (AM-07) ----------------------------------- + + +def test_workflow_paths_are_the_runtimes_repository_skill_files() -> None: + assert CLAUDE.workflow_file == ".claude/skills/agent-memory/SKILL.md" + assert CODEX.workflow_file == ".agents/skills/agent-memory/SKILL.md" + + +def test_workflow_file_is_the_shipped_text_with_the_runtime_name(spec: RuntimeSpec, repo: Path, home: Path) -> None: + run_setup(spec, repo, home) + wrapper = repo / spec.workflow_file + text = wrapper.read_text(encoding="utf-8") + assert text == workflow_text(spec) and f"workflow for {spec.name}" in text and f"--agent {spec.name}" in text + assert text.startswith("---\nname: agent-memory\n") + assert not wrapper.stat().st_mode & stat.S_IXUSR + + +def test_edited_workflow_file_is_a_named_conflict_and_kept(spec: RuntimeSpec, repo: Path, home: Path, capsys: pytest.CaptureFixture[str]) -> None: + run_setup(spec, repo, home) + wrapper = repo / spec.workflow_file + wrapper.write_text("# mine\n", encoding="utf-8") + again = run_setup(spec, repo, home) + assert [step.action for step in again.plan.conflicts] == [WORKFLOW_CONFLICT] + assert "edited, kept" in again.plan.conflicts[0].detail + assert wrapper.read_text(encoding="utf-8") == "# mine\n" + assert _load(again.plan.receipt_path)["managed"][2]["state"] == "conflict" + assert main(["setup", spec.name, "--home", str(home), "--repo", str(repo)]) == 3 + assert f"! {spec.workflow_file}: conflict, kept" in capsys.readouterr().out + + +def test_earlier_workflow_template_is_regenerated(spec: RuntimeSpec, repo: Path, home: Path) -> None: + run_setup(spec, repo, home) + wrapper = repo / spec.workflow_file + wrapper.write_text("# earlier\n", encoding="utf-8") + earlier = dataclasses.replace(spec, previous_workflow_digests=(_sha(b"# earlier\n"),)) + again = run_setup(earlier, repo, home) + assert REGENERATE_WORKFLOW in _actions(again) and not again.plan.conflicts + assert wrapper.read_text(encoding="utf-8") == workflow_text(spec) + + +def test_symlinked_workflow_file_is_never_written_through(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: + shared = tmp_path / "shared-skill.md" + shared.write_text("# shared\n", encoding="utf-8") + wrapper = repo / spec.workflow_file + wrapper.parent.mkdir(parents=True) + wrapper.symlink_to(shared) + result = run_setup(spec, repo, home) + assert [step.action for step in result.plan.conflicts] == [WORKFLOW_CONFLICT] + assert "is a symlink to" in result.plan.conflicts[0].detail + assert shared.read_text(encoding="utf-8") == "# shared\n" and wrapper.is_symlink() + assert _load(result.plan.receipt_path)["managed"][2]["symlink"] is True + + +def test_setup_accepts_a_repository_without_git(spec: RuntimeSpec, home: Path, tmp_path: Path) -> None: + scratch = tmp_path / "scratch" + scratch.mkdir() + result = run_setup(spec, scratch, home) + assert not result.plan.conflicts + assert (scratch / spec.script_file).is_file() and (scratch / spec.workflow_file).is_file() diff --git a/tests/test_startup.py b/tests/test_startup.py new file mode 100644 index 0000000..4be882b --- /dev/null +++ b/tests/test_startup.py @@ -0,0 +1,384 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory startup --runtime ``: the bounded, ordered, honest read manifest. + +The seven tier files in layout order with readability states only, the +exclusions, the optional pull with its outcome, the character budget, the +runtime shapes (plain text for claude, the hook envelope for codex), and the +project taken from a binding or a marker. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from agent_memory import layout, org, sync +from agent_memory.cli import main +from agent_memory.home import BINDING_FILE, ENV_COMPAT_HOME, ENV_HOME +from agent_memory.startup import ( + DEFAULT_MAX_CHARS, + MISSING, + READABLE, + UNREADABLE, + build_manifest, + render_codex_hook, + render_text, +) + +not_root = pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") + +ORDER = [ + "projects/demo/memory/project_facts.md", + "projects/demo/memory/decision_log.md", + "projects/demo/memory/open_threads.md", + "projects/demo/memory/known_debt.md", + "org-memory/recent.md", + "org-memory/decisions.md", + "org-memory/rules.md", +] + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + home = tmp_path / "home" + org.init(home, project="demo") + return home + + +@pytest.fixture +def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + monkeypatch.delenv(ENV_HOME, raising=False) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_manifest_lists_the_tier_files_in_layout_order(home: Path) -> None: + manifest = build_manifest(home, runtime="claude", project="demo", home_source="flag", project_source="flag") + assert list(manifest)[0] == "schema_version" and manifest["schema_version"] == 1 + assert manifest["runtime"] == "claude" and manifest["content_injected"] is False + assert [entry["relative"] for entry in manifest["files"]] == ORDER + assert [entry["tier"] for entry in manifest["files"]] == ["project"] * 4 + ["org"] * 3 + for entry in manifest["files"]: + assert entry["state"] == READABLE and entry["bytes"] > 0 and entry["modified_at_utc"].endswith("Z") + assert entry["path"] == str(home / entry["relative"]) + assert manifest["bytes_total"] == sum(entry["bytes"] for entry in manifest["files"]) + assert manifest["excluded"] == ["org-memory/events/", "org-memory/debriefs/", "projects/demo/memory/archive/"] + assert manifest["pull"] == {"requested": False, "status": "not_requested", "ok": True, "lines": []} + assert manifest["sync"] == {"marker": False, "last_commit_at_utc": None} + assert manifest["warnings"] == [] and manifest["result"] == "ok" + assert manifest["generated_at_utc"].endswith("Z") + + +@not_root +def test_missing_and_unreadable_files_are_named_not_read(home: Path) -> None: + (home / "projects" / "demo" / "memory" / "known_debt.md").unlink() + rules = home / "org-memory" / "rules.md" + rules.chmod(0) + if os.access(rules, os.R_OK): + rules.chmod(0o644) + pytest.skip("this user reads files regardless of their mode bits") + try: + manifest = build_manifest(home, runtime="claude", project="demo") + finally: + rules.chmod(0o644) + states = {entry["relative"]: entry["state"] for entry in manifest["files"]} + assert states["projects/demo/memory/known_debt.md"] == MISSING + assert states["org-memory/rules.md"] == UNREADABLE + assert manifest["result"] == "degraded" + assert any(warning.startswith("projects/demo/memory/known_debt.md: missing") for warning in manifest["warnings"]) + assert any(warning.startswith("org-memory/rules.md: unreadable (") for warning in manifest["warnings"]) + + +def test_a_directory_at_a_file_slot_is_unreadable(home: Path) -> None: + recent = home / "org-memory" / "recent.md" + recent.unlink() + recent.mkdir() + manifest = build_manifest(home, runtime="claude", project="demo") + entry = next(item for item in manifest["files"] if item["name"] == "recent.md") + assert entry["state"] == UNREADABLE and entry["error"] == "not a regular file" + + +def test_no_project_lists_the_org_files_with_a_warning(home: Path) -> None: + manifest = build_manifest(home, runtime="claude") + assert [entry["relative"] for entry in manifest["files"]] == ORDER[4:] + assert manifest["project"] is None and manifest["project_source"] is None + assert manifest["excluded"] == ["org-memory/events/", "org-memory/debriefs/"] + assert manifest["warnings"] == [ + "no project resolved; pass --project or bind the repository with `agent-memory init --repo .`" + ] + + +def test_a_bad_project_name_is_a_warning_not_a_crash(home: Path) -> None: + manifest = build_manifest(home, runtime="claude", project="../escape") + assert manifest["project"] is None + assert [entry["relative"] for entry in manifest["files"]] == ORDER[4:] + assert manifest["warnings"][0].startswith("project '../escape':") + + +def test_a_missing_home_is_all_missing(tmp_path: Path) -> None: + manifest = build_manifest(tmp_path / "nope", runtime="claude", project="demo") + assert {entry["state"] for entry in manifest["files"]} == {MISSING} + assert manifest["warnings"][0].endswith("is not a directory; every file is missing") + assert manifest["result"] == "degraded" + + +def test_unknown_runtime_is_rejected(home: Path) -> None: + with pytest.raises(ValueError, match="unknown runtime"): + build_manifest(home, runtime="vim") + + +# --- the pull --------------------------------------------------------------- + + +def test_pull_runs_and_reports_the_sync(home: Path, tmp_path: Path, git_env: None) -> None: + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "--quiet", str(remote)], check=True) + assert main(["enable", "--home", str(home), "--remote", str(remote)]) == 0 + manifest = build_manifest(home, runtime="claude", project="demo", pull=True) + assert manifest["pull"] == {"requested": True, "status": "up_to_date", "ok": True, "lines": ["memory pull: already synced."]} + assert manifest["sync"]["marker"] is True and manifest["sync"]["last_commit_at_utc"].endswith("Z") + assert manifest["result"] == "ok" + text = render_text(manifest) + assert "memory pull: already synced." in text and "memory sync: last commit " in text + + +def test_pull_without_the_marker_is_silent(home: Path) -> None: + manifest = build_manifest(home, runtime="claude", project="demo", pull=True) + assert manifest["pull"] == {"requested": True, "status": "not_configured", "ok": True, "lines": []} + assert manifest["warnings"] == [] + assert "memory pull: sync is not enabled for this home; skipped." in render_text(manifest) + + +def test_failed_pull_is_a_warning_not_a_failure(home: Path, tmp_path: Path, git_env: None) -> None: + assert main(["enable", "--home", str(home), "--remote", str(tmp_path / "missing.git")]) == 1 + manifest = build_manifest(home, runtime="claude", project="demo", pull=True) + assert manifest["pull"]["requested"] is True and manifest["pull"]["ok"] is False + assert manifest["pull"]["status"] == "fetch_failed" + assert manifest["result"] == "degraded" + assert any("local memory may be stale" in warning for warning in manifest["warnings"]) + # The retained local files are still described. + assert [entry["state"] for entry in manifest["files"]] == [READABLE] * 7 + assert manifest["bytes_total"] == sum((home / relative).stat().st_size for relative in ORDER) + + +def test_pull_runs_before_the_files_are_inspected(home: Path, tmp_path: Path, git_env: None) -> None: + """Sizes, times and states describe the tree the pull left: an updated, an added and a removed file.""" + updated, added, removed = ORDER[0], ORDER[2], ORDER[3] + (home / added).unlink() # absent locally until the peer publishes it + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "--quiet", str(remote)], check=True) + assert main(["enable", "--home", str(home), "--remote", str(remote)]) == 0 + peer = tmp_path / "peer" + subprocess.run(["git", "clone", "--quiet", str(remote), str(peer)], check=True) + (peer / updated).write_text("UPDATED AFTER PULL\n", encoding="utf-8") + (peer / added).write_text("ADDED AFTER PULL\n", encoding="utf-8") + (peer / removed).unlink() + subprocess.run(["git", "add", "-A", "projects"], cwd=str(peer), check=True) + subprocess.run(["git", "commit", "--quiet", "-m", "peer changes"], cwd=str(peer), check=True) + subprocess.run(["git", "push", "--quiet"], cwd=str(peer), check=True) + before = (home / updated).stat().st_size + assert before != len("UPDATED AFTER PULL\n") and (home / removed).is_file() and not (home / added).exists() + + manifest = build_manifest(home, runtime="claude", project="demo", pull=True) + assert manifest["pull"]["status"] == "synced" and manifest["pull"]["ok"] is True + by_path = {entry["relative"]: entry for entry in manifest["files"]} + assert by_path[updated]["state"] == READABLE and by_path[updated]["bytes"] == len("UPDATED AFTER PULL\n") + assert by_path[added]["state"] == READABLE and by_path[added]["bytes"] == len("ADDED AFTER PULL\n") + assert by_path[removed]["state"] == MISSING and by_path[removed]["bytes"] == 0 + assert by_path[updated]["bytes"] == (home / updated).stat().st_size + assert manifest["bytes_total"] == sum(entry["bytes"] for entry in manifest["files"]) + assert [warning for warning in manifest["warnings"] if removed in warning] == [f"{removed}: missing"] + + +def test_pull_error_is_reported_not_raised(home: Path) -> None: + (home / layout.MARKER_FILE).write_text("", encoding="utf-8") # the marker without a repository + manifest = build_manifest(home, runtime="claude", project="demo", pull=True) + assert manifest["pull"]["status"] == "error" and manifest["pull"]["ok"] is False + assert manifest["pull"]["lines"][0].startswith("memory pull: ") + assert manifest["sync"]["marker"] is True and manifest["sync"]["last_commit_at_utc"] is None + assert sync.is_configured(home) + + +# --- rendering --------------------------------------------------------------- + + +def test_text_lists_files_in_order_and_claims_no_read(home: Path) -> None: + manifest = build_manifest(home, runtime="claude", project="demo", home_source="flag", project_source="flag") + text = render_text(manifest) + lines = text.splitlines() + assert lines[0] == f"agent-memory startup (claude): home {home} (flag), project demo (flag)" + numbered = [line for line in lines if line.strip()[:2] in {f"{n}." for n in range(1, 8)}] + assert [line.split()[1].rstrip(":") for line in numbered] == ORDER + assert all(": readable, " in line for line in numbered) + assert "no content is injected" in text + assert "not read by default" in text + assert "Excluded by default: org-memory/events/, org-memory/debriefs/, projects/demo/memory/archive/" in text + assert "Warnings:" not in text + assert len(text) <= DEFAULT_MAX_CHARS + + +def test_text_is_cut_at_the_budget_with_a_notice(home: Path) -> None: + manifest = build_manifest(home, runtime="claude", project="demo") + text = render_text(manifest, max_chars=200) + assert len(text) <= 200 + assert text.endswith("--json` for the whole manifest]\n") + assert render_text(manifest, max_chars=100_000) == render_text(manifest) + + +@pytest.mark.parametrize("budget", [0, 1, 5, 6, 50, 100, 126, 127, 128, 150]) +def test_every_budget_bounds_the_text_notice_included(home: Path, budget: int) -> None: + manifest = build_manifest(home, runtime="claude", project="demo") + text = render_text(manifest, max_chars=budget) + assert len(text) <= budget + assert text == render_text(manifest)[:budget] or text.endswith(("[cut]\n"[:budget], "for the whole manifest]\n")) + envelope = render_codex_hook(manifest, max_chars=budget) + assert len(envelope["hookSpecificOutput"]["additionalContext"]) <= budget + assert render_text(manifest, max_chars=-1) == "" + + +def test_warnings_render_as_a_section(home: Path) -> None: + manifest = build_manifest(home, runtime="claude") + text = render_text(manifest) + assert "Warnings:\n - no project resolved;" in text + + +def test_codex_envelope_carries_the_text(home: Path) -> None: + manifest = build_manifest(home, runtime="codex", project="demo") + envelope = render_codex_hook(manifest) + assert envelope == { + "continue": True, + "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": render_text(manifest)}, + } + degraded = render_codex_hook(build_manifest(home, runtime="codex")) + assert "degraded" in degraded["systemMessage"] + assert "Warnings:" in degraded["hookSpecificOutput"]["additionalContext"] + + +# --- the command line -------------------------------------------------------- + + +def test_cli_startup_text_json_and_codex_shapes(home: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["startup", "--runtime", "claude", "--home", str(home), "--project", "demo"]) == 0 + out = capsys.readouterr().out + assert out.startswith(f"agent-memory startup (claude): home {home} (flag), project demo (flag)\n") + assert main(["startup", "--runtime", "claude", "--home", str(home), "--project", "demo", "--json"]) == 0 + manifest = json.loads(capsys.readouterr().out) + assert manifest["schema_version"] == 1 and manifest["project"] == "demo" and manifest["home_source"] == "flag" + assert main(["startup", "--runtime", "codex", "--home", str(home), "--project", "demo"]) == 0 + envelope = json.loads(capsys.readouterr().out) + assert envelope["continue"] is True and envelope["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert main(["startup", "--runtime", "claude", "--home", str(home), "--project", "demo", "--max-chars", "150"]) == 0 + assert len(capsys.readouterr().out) <= 150 + for argv in (["startup"], ["startup", "--runtime", "vim"]): + with pytest.raises(SystemExit) as exit_info: + main(argv) + assert exit_info.value.code == 2 + + +@pytest.mark.parametrize("value", ["0", "-1", "x"]) +def test_cli_startup_rejects_a_budget_below_the_minimum(home: Path, value: str, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + main(["startup", "--runtime", "claude", "--home", str(home), "--max-chars", value]) + assert exit_info.value.code == 2 + assert "--max-chars" in capsys.readouterr().err + + +# --- the project, when a flag or an environment variable chose the home ------ + + +def _bind(repo: Path, home: Path, project: str = "demo") -> None: + repo.mkdir(exist_ok=True) + (repo / BINDING_FILE).write_text(json.dumps({"schema_version": 1, "project": project, "home": str(home)}), encoding="utf-8") + + +def _mark(repo: Path, home: Path) -> None: + workspace = home / "projects" / "demo" / "workspace.json" + workspace.write_text("{}\n", encoding="utf-8") + repo.mkdir(exist_ok=True) + (repo / ".oacp").symlink_to(workspace) + + +@pytest.mark.parametrize("chooser", ["flag", ENV_HOME, ENV_COMPAT_HOME]) +@pytest.mark.parametrize("kind", ["binding", "marker"]) +def test_cli_startup_keeps_the_project_when_the_home_comes_from_elsewhere( + home: Path, isolated: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], chooser: str, kind: str +) -> None: + repo = isolated / "repo" + (_bind if kind == "binding" else _mark)(repo, home) + os.chdir(repo) + argv = ["startup", "--runtime", "claude", "--json"] + if chooser == "flag": + argv += ["--home", str(home)] + else: + monkeypatch.setenv(chooser, str(home)) + assert main(argv) == 0 + manifest = json.loads(capsys.readouterr().out) + assert manifest["home_source"] == ("flag" if chooser == "flag" else f"env:{chooser}") + assert manifest["project"] == "demo" and manifest["project_source"].startswith(f"{kind}:") + assert [entry["tier"] for entry in manifest["files"]].count("project") == 4 + assert manifest["warnings"] == [] + + +def test_cli_startup_borrows_no_project_from_a_binding_for_another_home( + home: Path, isolated: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + other = isolated / "other-home" + org.init(other, project="demo") + repo = isolated / "repo" + _bind(repo, other) + os.chdir(repo) + monkeypatch.setenv(ENV_HOME, str(home)) + assert main(["startup", "--runtime", "claude", "--json"]) == 0 + manifest = json.loads(capsys.readouterr().out) + assert manifest["home"] == str(home) and manifest["project"] is None and manifest["project_source"] is None + assert [entry["tier"] for entry in manifest["files"]] == ["org"] * 3 + assert manifest["warnings"][0].startswith(f"binding:{repo / BINDING_FILE} binds this repository to {other}, not to {home}") + assert "no project resolved" in manifest["warnings"][1] + + +def test_cli_startup_with_a_flag_home_still_fails_closed_on_a_broken_binding( + home: Path, isolated: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (isolated / BINDING_FILE).write_text("{broken", encoding="utf-8") + assert main(["startup", "--runtime", "claude", "--home", str(home)]) == 2 + assert "cannot read binding" in capsys.readouterr().err + + +def test_cli_startup_takes_the_project_from_the_binding(home: Path, isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + repo = isolated / "repo" + repo.mkdir() + (repo / BINDING_FILE).write_text(json.dumps({"schema_version": 1, "project": "demo", "home": str(home)}), encoding="utf-8") + os.chdir(repo) + assert main(["startup", "--runtime", "claude", "--json"]) == 0 + manifest = json.loads(capsys.readouterr().out) + assert manifest["home"] == str(home) and manifest["project"] == "demo" + assert manifest["home_source"].startswith("binding:") and manifest["project_source"] == manifest["home_source"] + assert [entry["state"] for entry in manifest["files"]] == [READABLE] * 7 + + +def test_cli_startup_takes_the_project_from_a_workspace_marker(home: Path, isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + workspace = home / "projects" / "demo" / "workspace.json" + workspace.write_text("{}\n", encoding="utf-8") + repo = isolated / "repo" + repo.mkdir() + (repo / ".oacp").symlink_to(workspace) + os.chdir(repo) + assert main(["startup", "--runtime", "claude", "--json"]) == 0 + manifest = json.loads(capsys.readouterr().out) + assert manifest["home"] == str(home.resolve()) and manifest["project"] == "demo" + assert manifest["home_source"] == f"marker:{repo / '.oacp'}" and manifest["project_source"] == manifest["home_source"] + assert manifest["warnings"] == [] + + +def test_cli_startup_project_flag_wins_over_the_binding(home: Path, isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + (isolated / BINDING_FILE).write_text(json.dumps({"schema_version": 1, "project": "demo", "home": str(home)}), encoding="utf-8") + assert main(["startup", "--runtime", "claude", "--project", "other", "--json"]) == 0 + manifest = json.loads(capsys.readouterr().out) + assert manifest["project"] == "other" and manifest["project_source"] == "flag" + assert [entry["state"] for entry in manifest["files"][:4]] == [MISSING] * 4 diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..bf78644 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory status``: the readout and the exit contract (0 clean, 1 dirty or diverged).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import pytest + +from agent_memory import layout, status, sync +from agent_memory.cli import main +from agent_memory.git_runner import GitResult, run_git +from agent_memory.home import HomeResolution, resolve_home + +from conftest import git, synced_home, write + + +@pytest.fixture +def out(capsys: pytest.CaptureFixture[str]): + def read() -> List[str]: + return capsys.readouterr().out.splitlines() + + return read + + +class RecordingRunner: + """The real runner, recording every call and its timeout.""" + + def __init__(self) -> None: + self.calls: List[Tuple[Tuple[str, ...], Optional[float]]] = [] + + def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: + self.calls.append((tuple(args), timeout)) + return run_git(args, cwd=cwd, timeout=timeout) + + +def _resolution(home: Path) -> HomeResolution: + return resolve_home(str(home)) + + +# --- the readout ------------------------------------------------------------ + + +def test_a_home_without_the_marker_reports_sync_not_configured(tmp_path: Path, out) -> None: + home = tmp_path / "home" + layout.scaffold_home(home) + assert main(["status", "--home", str(home)]) == 0 + lines = out() + assert lines[-1] == "sync: not configured" + assert not any(line.startswith(("tree:", "fetch:")) for line in lines) + + +def test_a_marker_without_a_repository_is_reported_and_does_not_fail(tmp_path: Path, out) -> None: + home = tmp_path / "home" + layout.scaffold_home(home) + sync.write_marker(home) + assert main(["status", "--home", str(home)]) == 0 + assert out()[-1] == "sync: marker present, but the home is not a git repository" + + +def test_a_home_inside_another_repository_is_reported_without_reading_that_repository( + tmp_path: Path, git_env: None, out +) -> None: + outer = tmp_path / "outer" + outer.mkdir() + git("init", "--quiet", cwd=outer) + write(outer / "unrelated.txt", "dirty outer tree\n") + home = outer / "home" + layout.scaffold_home(home) + sync.write_marker(home) + assert main(["status", "--home", str(home), "--fetch"]) == 0 + lines = out() + assert lines[-1] == f"sync: marker present, but the home is inside the repository at {outer.resolve()}, not one of its own" + assert not any(line.startswith(("tree:", "fetch:")) for line in lines) + + +def test_a_clean_synced_home_exits_zero(tmp_path: Path, git_env: None, out) -> None: + home, _ = synced_home(tmp_path) + assert main(["status", "--home", str(home)]) == 0 + lines = out() + assert "marker: present" in lines + assert "gitignore: canonical" in lines + assert lines[-3:] == ["sync: synced with upstream", "fetch: skipped (pass --fetch to contact the remote)", "tree: clean"] + + +def test_a_dirty_tree_exits_one(tmp_path: Path, git_env: None, out) -> None: + home, _ = synced_home(tmp_path) + write(home / layout.ORG.pattern / "recent.md", "unpublished\n") + assert main(["status", "--home", str(home)]) == 1 + assert out()[-1] == "tree: dirty" + + +def test_a_diverged_home_exits_one(tmp_path: Path, git_env: None, out) -> None: + home, remote = synced_home(tmp_path) + other = tmp_path / "other" + git("clone", "--quiet", str(remote), str(other), cwd=tmp_path) + write(other / layout.ORG.pattern / "rules.md", "theirs\n") + git("add", "org-memory/rules.md", cwd=other) + git("commit", "--quiet", "-m", "theirs", cwd=other) + git("push", "--quiet", cwd=other) + write(home / layout.ORG.pattern / "decisions.md", "mine\n") + git("add", "org-memory/decisions.md", cwd=home) + git("commit", "--quiet", "-m", "mine", cwd=home) + + assert main(["status", "--home", str(home)]) == 0 # the stale upstream ref still reads as ahead + assert "sync: ahead by 1 unpushed commit(s)" in out() + assert main(["status", "--home", str(home), "--fetch"]) == 1 + lines = out() + assert "sync: DIVERGED from upstream (1 ahead, 1 behind)" in lines + assert "fetch: done" in lines + assert lines[-1] == "tree: clean" + + +def test_ahead_and_behind_are_reported_not_failed(tmp_path: Path, git_env: None, out) -> None: + home, remote = synced_home(tmp_path) + write(home / layout.ORG.pattern / "decisions.md", "mine\n") + git("add", "org-memory/decisions.md", cwd=home) + git("commit", "--quiet", "-m", "mine", cwd=home) + assert main(["status", "--home", str(home)]) == 0 + assert "sync: ahead by 1 unpushed commit(s)" in out() + + git("push", "--quiet", cwd=home) + git("reset", "--quiet", "--hard", "HEAD~1", cwd=home) + assert main(["status", "--home", str(home), "--fetch"]) == 0 + assert "sync: BEHIND upstream by 1 commit(s)" in out() + + +def test_local_only_home_has_no_fetch_line(tmp_path: Path, git_env: None, out) -> None: + home = tmp_path / "home" + assert sync.init(home).ok + assert main(["status", "--home", str(home), "--fetch"]) == 0 + lines = out() + assert lines[-2:] == ["sync: local-only; no remote configured", "tree: clean"] + + +def test_a_failed_fetch_is_reported_and_the_exit_follows_the_tree(tmp_path: Path, git_env: None, out) -> None: + home, remote = synced_home(tmp_path) + git("remote", "set-url", "origin", str(tmp_path / "gone.git"), cwd=home) + assert main(["status", "--home", str(home), "--fetch"]) == 0 + assert any(line.startswith("sync: remote fetch failed: ") for line in out()) + + +def test_fetch_is_opt_in_and_carries_the_network_timeout(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + quiet = RecordingRunner() + status.inspect(_resolution(home), runner=quiet) + assert not any(call[:1] == ("fetch",) for call, _ in quiet.calls) + assert all(timeout is None for _, timeout in quiet.calls) + + contacting = RecordingRunner() + readout = status.inspect(_resolution(home), fetch=True, runner=contacting) + assert [timeout for call, timeout in contacting.calls if call[:1] == ("fetch",)] == [sync.NETWORK_TIMEOUT_SECONDS] + assert readout.fetched is True + assert readout.exit_code == 0 + + +def test_gitignore_states(tmp_path: Path) -> None: + home = tmp_path / "home" + layout.scaffold_home(home) + assert status.gitignore_state(home) == "canonical" + path = home / layout.GITIGNORE_FILE + path.write_text(layout.gitignore_text() + "*.swp\n", encoding="utf-8") + assert status.gitignore_state(home) == "canonical managed block, other lines kept" + path.write_text("*\n", encoding="utf-8") + assert status.gitignore_state(home) == "present, differs from canonical" + path.write_bytes(b"\xff\xfe not text") + assert status.gitignore_state(home) == "present, differs from canonical" + path.unlink() + assert status.gitignore_state(home) == "absent" + + +def test_status_changes_nothing(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + write(home / layout.ORG.pattern / "recent.md", "unpublished\n") + before: Dict[str, bytes] = {str(p): p.read_bytes() for p in home.rglob("*") if p.is_file() and ".git" not in p.parts} + porcelain = git("status", "--porcelain", cwd=home) + assert main(["status", "--home", str(home), "--fetch"]) == 1 + assert {str(p): p.read_bytes() for p in home.rglob("*") if p.is_file() and ".git" not in p.parts} == before + assert git("status", "--porcelain", cwd=home) == porcelain diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..19586be --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,854 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import pytest + +from agent_memory import layout, sync +from agent_memory.git_runner import EXIT_TIMEOUT, GitResult, run_git + +GOLDEN = Path(__file__).resolve().parent / "golden" / "canonical_memory_gitignore.txt" +CANONICAL = layout.gitignore_text() +MARKER = layout.MARKER_FILE +RUNTIME_FILE = "projects/demo/agents/test/status.yaml" + + +@pytest.fixture(autouse=True) +def _isolated_git(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "init.defaultBranch") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "main") + for role in ("AUTHOR", "COMMITTER"): + monkeypatch.setenv(f"GIT_{role}_NAME", "Memory Test") + monkeypatch.setenv(f"GIT_{role}_EMAIL", "memory-test@example.invalid") + monkeypatch.delenv(sync.ENV_AGENT, raising=False) + monkeypatch.delenv("AGENT_NAME", raising=False) + + +# --- helpers ---------------------------------------------------------------- + + +def _git(*args: str, cwd: Path) -> str: + completed = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True, check=False) + if completed.returncode != 0: + raise AssertionError(f"git {' '.join(args)} failed ({completed.returncode}): {completed.stdout}\n{completed.stderr}") + return completed.stdout.strip() + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _create_remote(tmp_path: Path) -> Tuple[Path, Path]: + """A bare remote seeded with one memory commit on ``main``, plus the seed clone.""" + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + _git("init", "--bare", "--quiet", str(remote), cwd=tmp_path) + _git("init", "--quiet", str(seed), cwd=tmp_path) + _write(seed / ".gitignore", CANONICAL) + _write(seed / MARKER, "memory sync enabled\n") + _write(seed / "org-memory" / "recent.md", "seed\n") + _git("add", ".gitignore", MARKER, "org-memory/recent.md", cwd=seed) + _git("commit", "--quiet", "-m", "seed memory", cwd=seed) + _git("remote", "add", "origin", str(remote), cwd=seed) + _git("push", "--quiet", "-u", "origin", "main", cwd=seed) + _git("--git-dir", str(remote), "symbolic-ref", "HEAD", "refs/heads/main", cwd=tmp_path) + return remote, seed + + +def _commit_and_push(repo: Path, relative_path: str, content: str) -> None: + _write(repo / relative_path, content) + _git("add", relative_path, cwd=repo) + _git("commit", "--quiet", "-m", f"update {relative_path}", cwd=repo) + _git("push", "--quiet", cwd=repo) + + +def _head_paths(repo: Path) -> List[str]: + return sorted(line for line in _git("show", "--format=", "--name-only", "HEAD", cwd=repo).splitlines() if line) + + +def _staged(repo: Path) -> List[str]: + return sorted(line for line in _git("diff", "--cached", "--name-only", cwd=repo).splitlines() if line) + + +def _commit_count(repo: Path) -> int: + completed = subprocess.run( + ["git", "rev-list", "--count", "HEAD"], cwd=str(repo), capture_output=True, text=True, check=False + ) + return int(completed.stdout.strip()) if completed.returncode == 0 else 0 + + +def _remote_has(remote: Path, path: str) -> bool: + completed = subprocess.run( + ["git", "--git-dir", str(remote), "cat-file", "-e", f"main:{path}"], capture_output=True, check=False + ) + return completed.returncode == 0 + + +def _local_home(tmp_path: Path, name: str = "home") -> Path: + home = tmp_path / name + assert sync.init(home).ok + return home + + +class RecordingRunner: + """Answers scripted git calls without a repository and records every call with its timeout.""" + + def __init__(self, answers: Optional[Dict[Tuple[str, ...], str]] = None) -> None: + self.calls: List[Tuple[Tuple[str, ...], Optional[float]]] = [] + self.answers = answers or {} + + def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: + call = tuple(args) + self.calls.append((call, timeout)) + for prefix, stdout in self.answers.items(): + if call[: len(prefix)] == prefix: + return GitResult(0, stdout) + return GitResult(0, "") + + def timed(self, *prefix: str) -> List[Optional[float]]: + return [timeout for call, timeout in self.calls if call[: len(prefix)] == prefix] + + +class FetchTimesOut: + """The real runner, except that every fetch reports a timeout.""" + + def __init__(self) -> None: + self.calls: List[Tuple[str, ...]] = [] + + def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: + self.calls.append(tuple(args)) + if args and args[0] == "fetch": + return GitResult(EXIT_TIMEOUT, "", "git fetch --quiet: timed out after 30s") + return run_git(args, cwd=cwd, timeout=timeout) + + +# --- the network verbs carry the timeout ----------------------------------- + + +def test_fetch_pull_push_and_clone_receive_network_timeouts(tmp_path: Path) -> None: + root = tmp_path / "home" + root.mkdir() + _write(root / MARKER, "memory sync enabled\n") + common = { + ("rev-parse", "--is-inside-work-tree"): "true", + ("rev-parse", "--show-toplevel"): str(root), + ("remote",): "origin", + ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"): "origin/main", + } + puller = RecordingRunner({**common, ("rev-list", "--left-right", "--count", "HEAD...origin/main"): "0 1"}) + pulled = sync.pull(root, runner=puller) + pusher = RecordingRunner({**common, ("rev-list", "--left-right", "--count", "HEAD...origin/main"): "1 0"}) + pushed = sync.push(root, runner=pusher) + cloner = RecordingRunner() + sync.clone(tmp_path / "clone", "example.invalid/repo.git", runner=cloner) + + assert pulled.status == "synced" + assert pushed.status == "pushed" + assert puller.timed("fetch", "--quiet") == [sync.NETWORK_TIMEOUT_SECONDS] + assert puller.timed("pull", "--ff-only") == [sync.NETWORK_TIMEOUT_SECONDS] + assert pusher.timed("push") == [sync.NETWORK_TIMEOUT_SECONDS] + assert cloner.timed("clone") == [sync.NETWORK_TIMEOUT_SECONDS] + local_verbs = [call for call, timeout in puller.calls + pusher.calls if timeout is not None] + assert all(call[0] in {"fetch", "pull", "push"} for call in local_verbs) + + +def test_fetch_timeout_on_pull_changes_nothing_and_is_its_own_status(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/new.md", "remote update\n") + runner = FetchTimesOut() + + outcome = sync.pull(home, runner=runner) + + assert outcome.status == "fetch_timed_out" + assert not outcome.ok + assert "timed out" in outcome.lines[0] + assert not (home / "org-memory" / "new.md").exists() + assert not any(call[0] == "pull" for call in runner.calls) + + +def test_fetch_timeout_on_push_keeps_the_commit_local_and_skips_the_push(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _write(home / "org-memory" / "local.md", "local update\n") + runner = FetchTimesOut() + + outcome = sync.push(home, runner=runner) + + assert outcome.status == "fetch_timed_out" + assert not outcome.ok + assert outcome.committed == ("org-memory/local.md",) + assert "remains local" in outcome.lines[-1] + assert _head_paths(home) == ["org-memory/local.md"] + assert not any(call[0] == "push" for call in runner.calls) + assert not _remote_has(remote, "org-memory/local.md") + + +# --- the selected root must be the worktree root --------------------------- + + +@pytest.mark.parametrize("verb", ["init", "push", "pull"]) +def test_home_nested_inside_another_repository_is_refused_before_anything_is_written( + tmp_path: Path, verb: str +) -> None: + outer = tmp_path / "outer" + outer.mkdir() + _git("init", "--quiet", cwd=outer) + home = outer / "home" + home.mkdir() + _write(home / MARKER, "memory sync enabled\n") + _write(home / "org-memory" / "recent.md", "nested\n") + + with pytest.raises(sync.SyncError, match="root mismatch"): + getattr(sync, verb)(home) + + assert not (home / ".gitignore").exists() + assert not (home / ".git").exists() + assert _commit_count(outer) == 0 + assert _staged(outer) == [] + + +def test_marker_without_a_repository_is_an_error(tmp_path: Path) -> None: + home = tmp_path / "home" + _write(home / MARKER, "memory sync enabled\n") + with pytest.raises(sync.SyncError, match="not a git repository"): + sync.push(home) + with pytest.raises(sync.SyncError, match="not a git repository"): + sync.pull(home) + + +# --- the managed ignore block ---------------------------------------------- + + +def test_init_writes_the_golden_gitignore_on_a_fresh_home(tmp_path: Path) -> None: + home = tmp_path / "home" + outcome = sync.init(home) + assert outcome.ok and outcome.status == "local_only" + assert outcome.receipt is not None and outcome.receipt.action == "created" + assert (home / ".gitignore").read_bytes() == GOLDEN.read_bytes() + assert (home / MARKER).is_file() + assert _head_paths(home) == [".gitignore", MARKER] + + +def test_reinit_keeps_custom_gitignore_lines_and_returns_a_receipt(tmp_path: Path) -> None: + home = _local_home(tmp_path) + custom = "# unrelated existing rules\nprivate-notes/\n" + _write(home / ".gitignore", custom) + + outcome = sync.init(home) + + assert outcome.ok + receipt = outcome.receipt + assert receipt is not None and receipt.action == "updated" + assert receipt.before == custom + assert receipt.after == CANONICAL + custom + assert (home / ".gitignore").read_text(encoding="utf-8") == CANONICAL + custom + assert ".gitignore before:" in outcome.lines and ".gitignore after:" in outcome.lines + assert " private-notes/" in outcome.lines + assert _head_paths(home) == [".gitignore"] + + +@pytest.mark.parametrize( + "existing", + [CANONICAL, CANONICAL + "private-notes/\n", "# mine first\nprivate-notes/\n" + CANONICAL + "tail/\n"], + ids=["exact", "custom-tail", "custom-head-and-tail"], +) +def test_init_leaves_a_gitignore_that_already_carries_the_block_untouched(tmp_path: Path, existing: str) -> None: + home = _local_home(tmp_path) + _write(home / ".gitignore", existing) + before_count = _commit_count(home) + + outcome = sync.init(home) + + assert outcome.ok + assert outcome.receipt is not None and outcome.receipt.action == "unchanged" + assert (home / ".gitignore").read_text(encoding="utf-8") == existing + assert _commit_count(home) == before_count + (1 if existing != CANONICAL else 0) + + +def test_init_upgrades_an_older_block_without_duplicating_its_lines(tmp_path: Path) -> None: + home = _local_home(tmp_path) + older = "\n".join(line for line in CANONICAL.splitlines() if "keys" not in line) + "\n" + _write(home / ".gitignore", older + "private-notes/\n") + + outcome = sync.init(home) + + assert outcome.receipt is not None and outcome.receipt.action == "updated" + assert (home / ".gitignore").read_text(encoding="utf-8") == CANONICAL + "private-notes/\n" + + +def test_init_keeps_the_bytes_of_an_existing_marker(tmp_path: Path) -> None: + home = _local_home(tmp_path) + _write(home / MARKER, "legacy marker text\n") + assert sync.init(home).ok + assert (home / MARKER).read_text(encoding="utf-8") == "legacy marker text\n" + + +# --- the publication boundary ---------------------------------------------- + + +def test_prestaged_runtime_file_is_preserved_and_never_committed(tmp_path: Path) -> None: + home = _local_home(tmp_path) + _write(home / RUNTIME_FILE, "status: busy\n") + _git("add", "-f", RUNTIME_FILE, cwd=home) + _write(home / "org-memory" / "recent.md", "# recent\n") + + outcome = sync.push(home) + + assert outcome.ok and outcome.status == "local_only" + assert outcome.committed == ("org-memory/recent.md",) + assert outcome.preserved == (RUNTIME_FILE,) + assert any("left staged and uncommitted" in line and RUNTIME_FILE in line for line in outcome.lines) + assert _head_paths(home) == ["org-memory/recent.md"] + assert _staged(home) == [RUNTIME_FILE] + assert RUNTIME_FILE not in _git("ls-tree", "-r", "--name-only", "HEAD", cwd=home).splitlines() + assert (home / RUNTIME_FILE).read_text(encoding="utf-8") == "status: busy\n" + + +def test_prestaged_runtime_file_survives_init_on_an_unborn_branch(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + _git("init", "--quiet", cwd=home) + _write(home / RUNTIME_FILE, "status: busy\n") + _git("add", "-f", RUNTIME_FILE, cwd=home) + _write(home / "org-memory" / "recent.md", "# recent\n") + + outcome = sync.init(home) + + assert outcome.ok + assert set(outcome.committed) == {".gitignore", MARKER, "org-memory/recent.md"} + assert outcome.preserved == (RUNTIME_FILE,) + assert _head_paths(home) == [".gitignore", MARKER, "org-memory/recent.md"] + assert _staged(home) == [RUNTIME_FILE] + + +def test_staged_change_to_a_tracked_foreign_file_is_preserved(tmp_path: Path) -> None: + home = _local_home(tmp_path) + _write(home / RUNTIME_FILE, "status: idle\n") + _git("add", "-f", RUNTIME_FILE, cwd=home) + _git("commit", "--quiet", "-m", "hand-tracked runtime file", cwd=home) + _write(home / RUNTIME_FILE, "status: busy\n") + _git("add", RUNTIME_FILE, cwd=home) + _write(home / "org-memory" / "recent.md", "# recent\n") + + outcome = sync.push(home) + + assert outcome.ok + assert _head_paths(home) == ["org-memory/recent.md"] + assert _staged(home) == [RUNTIME_FILE] + assert _git("show", f"HEAD:{RUNTIME_FILE}", cwd=home) == "status: idle" + + +ALLOWED = ( + "org-memory/recent.md", + "org-memory/debriefs/demo/2026/09/20260905-alice-1f3a9c2b.md", + "projects/demo/memory/project_facts.md", + "projects/demo/memory/archive/20260101T000000Z_open_threads.md", + "projects/demo/memory/keys.md", +) +NEVER = ( + "keys/k.json", + "keys/.trust_domain", + "projects/demo/memory/keys/k.json", + "projects/demo/memory/.cache/index.json", + "projects/demo/agents/claude/inbox/msg.yaml", + "projects/demo/status.yaml", + "README.md", + "state/watch/cursor", +) + + +def test_every_committed_path_is_inside_the_allowlist(tmp_path: Path) -> None: + home = _local_home(tmp_path) + for path in ALLOWED + NEVER: + _write(home / path, f"{path}\n") + + outcome = sync.push(home) + + assert outcome.ok + assert set(outcome.committed) == set(ALLOWED) + assert _head_paths(home) == sorted(ALLOWED) + assert sorted(_git("ls-files", cwd=home).splitlines()) == sorted([".gitignore", MARKER, *ALLOWED]) + assert all(layout.is_allowed_memory_path(path) for path in _head_paths(home)) + for path in NEVER: + assert (home / path).is_file(), path + + +def test_a_widened_ignore_file_cannot_publish_an_unsynced_subdirectory(tmp_path: Path) -> None: + home = _local_home(tmp_path) + with (home / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write("!projects/*/memory/.cache/\n") + _write(home / "projects" / "demo" / "memory" / ".cache" / "index.json", "{}\n") + _write(home / "projects" / "demo" / "memory" / "project_facts.md", "# facts\n") + before = _commit_count(home) + + outcome = sync.push(home) + + assert outcome.status == "outside_allowlist" + assert not outcome.ok + assert "projects/demo/memory/.cache/index.json" in outcome.lines[0] + assert _commit_count(home) == before + assert _staged(home) == [] + assert (home / "projects" / "demo" / "memory" / "project_facts.md").is_file() + + +WIDEN_KEYS = "!keys/\n!**/keys/\n!**/keys/**\n" +NESTED_KEYS = ( + "projects/demo/memory/keys/k.json", + "org-memory/keys/k.json", + "projects/demo/memory/archive/keys/k.json", + "org-memory/debriefs/keys/k.json", +) + + +@pytest.mark.parametrize("verb", ["push", "init"]) +@pytest.mark.parametrize("denied", NESTED_KEYS) +def test_a_widened_ignore_file_cannot_publish_a_never_synced_directory(tmp_path: Path, verb: str, denied: str) -> None: + home = _local_home(tmp_path) + with (home / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write(WIDEN_KEYS) + _write(home / denied, "synthetic-key-fixture\n") + _write(home / "keys" / "root.json", "synthetic-key-fixture\n") + _write(home / "projects" / "demo" / "memory" / "keys.md", "# about keys\n") + _write(home / "org-memory" / "recent.md", "# recent\n") + head = _git("rev-parse", "HEAD", cwd=home) + + outcome = getattr(sync, verb)(home) + + assert outcome.status == "outside_allowlist" and not outcome.ok + assert denied in outcome.lines[-1] and "keys/" in outcome.lines[-1] + assert "root.json" not in outcome.lines[-1] + assert _git("rev-parse", "HEAD", cwd=home) == head + assert _staged(home) == [] + for path in (denied, "keys/root.json", "projects/demo/memory/keys.md", "org-memory/recent.md"): + assert (home / path).is_file(), path + assert "keys" not in _git("ls-files", cwd=home) + + +def test_a_never_synced_path_is_refused_before_anything_reaches_the_remote(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + with (home / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write(WIDEN_KEYS) + _write(home / "projects" / "demo" / "memory" / "keys" / "k.json", "synthetic-key-fixture\n") + _write(home / "org-memory" / "local.md", "local update\n") + head = _git("rev-parse", "HEAD", cwd=home) + + outcome = sync.push(home) + + assert outcome.status == "outside_allowlist" and not outcome.ok + assert _git("rev-parse", "HEAD", cwd=home) == head + assert not _remote_has(remote, "projects/demo/memory/keys/k.json") + assert not _remote_has(remote, "org-memory/local.md") + assert _git("--git-dir", str(remote), "rev-parse", "main", cwd=tmp_path) == head + + +def test_an_already_staged_never_synced_path_is_refused_not_committed(tmp_path: Path) -> None: + home = _local_home(tmp_path) + denied = "projects/demo/memory/keys/k.json" + _write(home / denied, "synthetic-key-fixture\n") + _git("add", "-f", denied, cwd=home) + _write(home / "org-memory" / "recent.md", "# recent\n") + head = _git("rev-parse", "HEAD", cwd=home) + + outcome = sync.push(home) + + assert outcome.status == "outside_allowlist" and not outcome.ok + assert _git("rev-parse", "HEAD", cwd=home) == head + assert _staged(home) == [denied] + assert "org-memory/recent.md" not in _git("ls-files", cwd=home).splitlines() + + +def test_keys_named_files_stay_allowed_while_keys_directories_never_are(tmp_path: Path) -> None: + home = _local_home(tmp_path) + _write(home / "projects" / "demo" / "memory" / "keys.md", "# about keys\n") + _write(home / "org-memory" / "keys-rotation.md", "# rotation\n") + outcome = sync.push(home) + assert outcome.ok + assert set(outcome.committed) == {"projects/demo/memory/keys.md", "org-memory/keys-rotation.md"} + + +# --- selected names are literal, never patterns --------------------------- + + +LITERAL_NAMES = ("*.md", "x?.md", "[x].md", ":x.md", "x*y.md", "a-b_c.md") + + +@pytest.mark.parametrize("name", LITERAL_NAMES) +def test_a_selected_name_with_pattern_characters_is_committed_as_itself(tmp_path: Path, name: str) -> None: + home = _local_home(tmp_path) + memory = "projects/demo/memory" + foreign = f"{memory}/.cache/x.md" + _write(home / foreign, "old-fixture\n") + _git("add", "-f", foreign, cwd=home) + _git("commit", "--quiet", "-m", "hand-tracked denied file, left clean", cwd=home) + _write(home / memory / name, "memory-fixture\n") + _write(home / memory / "x.md", "sibling\n") + if name == "*.md": + # The pattern hazard is real: read non-literally, the name reaches the hand-tracked denied file. + assert foreign in _git("ls-files", "--", f"{memory}/{name}", cwd=home).splitlines() + assert sync.select_paths(home) == (sorted([f"{memory}/{name}", f"{memory}/x.md"]), []) + + outcome = sync.push(home) + + assert outcome.ok + assert set(outcome.committed) == {f"{memory}/{name}", f"{memory}/x.md"} + assert _head_paths(home) == sorted([f"{memory}/{name}", f"{memory}/x.md"]) + assert _git("show", f"HEAD:{memory}/{name}", cwd=home) == "memory-fixture" + assert _git("show", f"HEAD:{foreign}", cwd=home) == "old-fixture" + assert _git("status", "--porcelain", cwd=home) == "" + + +def test_a_project_name_with_pattern_characters_never_matches_a_foreign_path(tmp_path: Path) -> None: + # The kernel's project-name grammar accepts "a*"; as a git pathspec it would also match + # projects/a/agents/memory/x.md. Every selected name must reach git literally. + home = _local_home(tmp_path) + foreign = "projects/a/agents/memory/x.md" + _write(home / foreign, "old-fixture\n") + _git("add", "-f", foreign, cwd=home) + _git("commit", "--quiet", "-m", "seed unrelated file", cwd=home) + _write(home / foreign, "staged-fixture\n") + _git("add", foreign, cwd=home) + _write(home / foreign, "unstaged-fixture\n") + memory = "projects/a*/memory/x.md" + _write(home / memory, "memory-fixture\n") + assert foreign in _git("ls-files", "--", memory, cwd=home).splitlines() # the pattern hazard is real + assert sync.select_paths(home) == ([memory], []) + assert sync.staged_paths(home) == [foreign] + head = _git("rev-parse", "HEAD", cwd=home) + + outcome = sync.push(home) + + assert outcome.ok and outcome.committed == (memory,) + assert outcome.preserved == (foreign,) + assert _git("rev-parse", "HEAD", cwd=home) != head + assert _head_paths(home) == [memory] + assert _git("show", f"HEAD:{foreign}", cwd=home) == "old-fixture" + assert _git("show", f":{foreign}", cwd=home) == "staged-fixture" + assert (home / foreign).read_text(encoding="utf-8") == "unstaged-fixture\n" + assert sync.staged_paths(home) == [foreign] + assert _git("show", f"HEAD:{memory}", cwd=home) == "memory-fixture" + + +def test_push_is_silent_when_not_configured(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + assert sync.push(home) == sync.Outcome("not_configured", True) + assert sync.pull(home) == sync.Outcome("not_configured", True) + + +# --- the commit identity --------------------------------------------------- + + +def test_commit_identity_carries_the_agent_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = _local_home(tmp_path) + + def publish(content: str, **kwargs: object) -> str: + _write(home / "org-memory" / "recent.md", content) + assert sync.push(home, **kwargs).ok # type: ignore[arg-type] + return _git("log", "-1", "--format=%s", cwd=home) + + assert publish("explicit\n", agent="claude").startswith("memory: claude@") + assert publish("env\n", env={sync.ENV_AGENT: "codex"}).startswith("memory: codex@") + assert publish("fallback\n", env={"AGENT_NAME": "iris", "USER": "osuser"}).startswith("memory: iris@") + assert publish("user\n", env={"USER": "osuser"}).startswith("memory: osuser@") + assert publish("none\n", env={}).startswith(f"memory: {sync.UNKNOWN_AGENT}@") + monkeypatch.setenv(sync.ENV_AGENT, "gemini") + assert publish("process env\n").startswith("memory: gemini@") + assert publish("count\n", agent="claude").endswith("(1 files)") + + +# --- push against a remote ------------------------------------------------- + + +def test_push_commits_dirty_memory_and_delivers_it(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _write(home / "org-memory" / "local.md", "local update\n") + + outcome = sync.push(home) + + assert outcome.status == "pushed" and outcome.ok + assert outcome.committed == ("org-memory/local.md",) + assert "delivered" in outcome.lines[-1] + assert _git("status", "--porcelain", cwd=home) == "" + assert _git("--git-dir", str(remote), "show", "main:org-memory/local.md", cwd=tmp_path) == "local update" + + +def test_push_without_a_remote_is_reported_as_local_only(tmp_path: Path) -> None: + home = _local_home(tmp_path) + _write(home / "org-memory" / "local.md", "local update\n") + outcome = sync.push(home) + assert outcome.status == "local_only" and outcome.ok + assert "remains local" in outcome.lines[-1] + + +def test_push_with_nothing_new_is_up_to_date(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + outcome = sync.push(home) + assert outcome.status == "up_to_date" and outcome.ok + assert outcome.committed == () + + +def test_push_refuses_a_repository_behind_its_upstream(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/remote.md", "remote update\n") + _write(home / "org-memory" / "local.md", "local update\n") + before = _commit_count(home) + + outcome = sync.push(home) + + assert outcome.status == "behind" and not outcome.ok + assert "pull before pushing" in outcome.lines[0] + assert _commit_count(home) == before + assert (home / "org-memory" / "local.md").read_text(encoding="utf-8") == "local update\n" + + +def test_push_refuses_a_diverged_repository_without_committing(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _write(home / "org-memory" / "local.md", "local update\n") + _git("add", "org-memory/local.md", cwd=home) + _git("commit", "--quiet", "-m", "local update", cwd=home) + _commit_and_push(seed, "org-memory/remote.md", "remote update\n") + _write(home / "org-memory" / "uncommitted.md", "# uncommitted\n") + before = _commit_count(home) + + outcome = sync.push(home) + + assert outcome.status == "diverged" and not outcome.ok + assert _commit_count(home) == before + assert (home / "org-memory" / "uncommitted.md").is_file() + + +def test_rejected_push_leaves_the_local_commit_in_place_and_says_so(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + hook = remote / "hooks" / "pre-receive" + hook.write_text("#!/bin/sh\necho 'memory remote refuses pushes' >&2\nexit 1\n", encoding="utf-8") + hook.chmod(0o755) + _write(home / "org-memory" / "local.md", "local update\n") + + outcome = sync.push(home) + + assert outcome.status == "push_failed" and not outcome.ok + assert outcome.committed == ("org-memory/local.md",) + assert "remains local" in outcome.lines[-1] + assert _head_paths(home) == ["org-memory/local.md"] + assert not _remote_has(remote, "org-memory/local.md") + + +# --- pull ------------------------------------------------------------------ + + +def test_pull_fast_forwards_a_clean_repository_behind_its_upstream(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/new.md", "remote update\n") + + outcome = sync.pull(home) + + assert outcome.status == "synced" and outcome.ok + assert outcome.lines == ("memory pull: synced 1 commit(s).",) + assert (home / "org-memory" / "new.md").read_text(encoding="utf-8") == "remote update\n" + + +def test_pull_refuses_a_dirty_tree(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/new.md", "remote update\n") + _write(home / "org-memory" / "recent.md", "edited locally\n") + + outcome = sync.pull(home) + + assert outcome.status == "dirty" and not outcome.ok + assert not (home / "org-memory" / "new.md").exists() + assert (home / "org-memory" / "recent.md").read_text(encoding="utf-8") == "edited locally\n" + + +def test_pull_refuses_a_diverged_repository(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _write(home / "org-memory" / "local.md", "local\n") + _git("add", "org-memory/local.md", cwd=home) + _git("commit", "--quiet", "-m", "local", cwd=home) + _commit_and_push(seed, "org-memory/remote.md", "remote\n") + + outcome = sync.pull(home) + + assert outcome.status == "diverged" and not outcome.ok + assert not (home / "org-memory" / "remote.md").exists() + + +def test_pull_with_unpushed_commits_is_not_an_error(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _write(home / "org-memory" / "local.md", "local\n") + _git("add", "org-memory/local.md", cwd=home) + _git("commit", "--quiet", "-m", "local", cwd=home) + + outcome = sync.pull(home) + + assert outcome.status == "ahead" and outcome.ok + assert "1 unpushed commit(s)" in outcome.lines[0] + + +def test_pull_when_already_synced(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + outcome = sync.pull(home) + assert outcome.status == "up_to_date" and outcome.ok + + +def test_pull_without_a_remote_or_upstream_is_a_skip(tmp_path: Path) -> None: + home = _local_home(tmp_path) + assert sync.pull(home).status == "local_only" + _git("remote", "add", "origin", str(tmp_path / "nowhere.git"), cwd=home) + _git("init", "--bare", "--quiet", str(tmp_path / "nowhere.git"), cwd=tmp_path) + outcome = sync.pull(home) + assert outcome.status == "no_upstream" and outcome.ok + + +# --- clone ----------------------------------------------------------------- + + +def test_clone_refuses_a_non_empty_home_without_force(tmp_path: Path) -> None: + home = tmp_path / "home" + _write(home / "local-only.txt", "keep me\n") + with pytest.raises(sync.SyncError, match="non-empty home"): + sync.clone(home, str(tmp_path / "missing.git")) + assert (home / "local-only.txt").is_file() + + +def test_force_clone_preserves_the_existing_home_as_a_backup(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + _write(home / "local-only.txt", "keep me\n") + + outcome = sync.clone(home, str(remote), force=True) + + backups = list(tmp_path.glob("home.backup-*")) + assert len(backups) == 1 + assert (backups[0] / "local-only.txt").read_text(encoding="utf-8") == "keep me\n" + assert (home / "org-memory" / "recent.md").read_text(encoding="utf-8") == "seed\n" + assert outcome.status == "cloned" + assert any("Moved the existing home aside" in line for line in outcome.lines) + + +def test_force_clone_failure_restores_the_existing_home(tmp_path: Path) -> None: + home = tmp_path / "home" + _write(home / "local-only.txt", "keep me\n") + with pytest.raises(sync.SyncError, match="git clone failed"): + sync.clone(home, str(tmp_path / "missing.git"), force=True) + assert (home / "local-only.txt").read_text(encoding="utf-8") == "keep me\n" + assert not list(tmp_path.glob("home.backup-*")) + + +# --- init and the round trip ------------------------------------------------ + + +def test_bare_remote_round_trip(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + _git("init", "--bare", "--quiet", str(remote), cwd=tmp_path) + first = tmp_path / "first" + _write(first / "org-memory" / "recent.md", "# recent\n") + + initialized = sync.init(first, remote=str(remote), agent="claude") + assert initialized.status == "pushed" and initialized.ok + assert set(initialized.committed) == {".gitignore", MARKER, "org-memory/recent.md"} + + second = tmp_path / "second" + assert sync.clone(second, str(remote)).ok + assert (second / "org-memory" / "recent.md").read_text(encoding="utf-8") == "# recent\n" + assert (second / ".gitignore").read_bytes() == GOLDEN.read_bytes() + assert sync.is_configured(second) + + _write(first / "projects" / "demo" / "memory" / "decision_log.md", "- decided\n") + pushed = sync.push(first, agent="claude") + assert pushed.status == "pushed" and pushed.committed == ("projects/demo/memory/decision_log.md",) + + pulled = sync.pull(second) + assert pulled.status == "synced" + assert (second / "projects" / "demo" / "memory" / "decision_log.md").read_text(encoding="utf-8") == "- decided\n" + assert _git("log", "-1", "--format=%s", cwd=second).startswith("memory: claude@") + + +def test_init_is_idempotent(tmp_path: Path) -> None: + home = _local_home(tmp_path) + before = {path: path.read_bytes() for path in home.rglob("*") if path.is_file() and ".git" not in path.parts} + count = _commit_count(home) + + outcome = sync.init(home) + + assert outcome.ok and outcome.status == "local_only" + assert "no memory changes to commit" in " ".join(outcome.lines) + assert _commit_count(home) == count + assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file() and ".git" not in path.parts} == before + + +def test_init_adds_origin_when_another_remote_exists(tmp_path: Path) -> None: + remote = tmp_path / "memory.git" + other = tmp_path / "other.git" + _git("init", "--bare", "--quiet", str(remote), cwd=tmp_path) + _git("init", "--bare", "--quiet", str(other), cwd=tmp_path) + home = tmp_path / "home" + home.mkdir() + _git("init", "--quiet", cwd=home) + _git("remote", "add", "upstream", str(other), cwd=home) + + outcome = sync.init(home, remote=str(remote)) + + assert outcome.ok and outcome.status == "pushed" + assert _git("remote", "get-url", "origin", cwd=home) == str(remote) + assert _remote_has(remote, MARKER) + + +def test_init_updates_an_existing_origin(tmp_path: Path) -> None: + home = _local_home(tmp_path) + _git("remote", "add", "origin", str(tmp_path / "old.git"), cwd=home) + remote = tmp_path / "new.git" + _git("init", "--bare", "--quiet", str(remote), cwd=tmp_path) + outcome = sync.init(home, remote=str(remote)) + assert outcome.ok + assert _git("remote", "get-url", "origin", cwd=home) == str(remote) + + +def test_disable_removes_the_marker_and_keeps_the_repository(tmp_path: Path) -> None: + home = _local_home(tmp_path) + outcome = sync.disable(home) + assert outcome.status == "disabled" and outcome.ok + assert not (home / MARKER).exists() + assert (home / ".git").is_dir() + assert sync.disable(home).status == "already_disabled" + assert sync.push(home).status == "not_configured" + + +# --- the status parser ------------------------------------------------------ + + +def test_status_parser_takes_both_paths_of_a_rename() -> None: + data = "R new.md\0old.md\0 M other.md\0?? fresh.md\0" + assert sync._parse_status_z(data) == ["new.md", "old.md", "other.md", "fresh.md"] + assert sync._parse_status_z("") == [] diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..c3efd35 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,359 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""``agent-memory capture`` and ``recall``, and the two-session recall proof. + +The capture entry and its placement (newest first, one heading per UTC day), +what capture refuses (a missing tier, a linked component below the home, an +empty decision), the +bounded read in manifest order with its budget and exclusions, the shipped +workflow text, the CLI shapes, and the acceptance bar of the workflow issue: +two fresh sessions in a scratch repository with no git and no credentials, +the first recording a seeded decision, the second receiving the bounded +context through the installed hook and reading the decision back. +""" + +from __future__ import annotations + +import datetime as dt +import json +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from agent_memory import layout, org, startup, workflow +from agent_memory.cli import main +from agent_memory.home import ENV_COMPAT_HOME, ENV_HOME +from agent_memory.setup import SPECS + +NOW = dt.datetime(2026, 9, 6, 7, 2, 11, tzinfo=dt.timezone.utc) +DECISION = "Use SQLite for the local cache." + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + home = tmp_path / "home" + org.init(home, project="demo") + return home + + +@pytest.fixture +def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """No home from the environment, and a cwd with no binding or marker above it.""" + monkeypatch.delenv(ENV_HOME, raising=False) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + monkeypatch.delenv(workflow.DEFAULT_AGENT_ENV, raising=False) + cwd = tmp_path / "elsewhere" + cwd.mkdir() + monkeypatch.chdir(cwd) + return cwd + + +def _log(home: Path, project: str = "demo") -> Path: + return layout.project_memory_dir(home, project) / workflow.DECISION_FILE + + +# --- the entry ----------------------------------------------------------------- + + +def test_entry_carries_the_decision_the_reason_and_the_provenance() -> None: + entry = workflow.format_entry(DECISION, why="it needs no daemon", agent="claude", source="issue #8", now=NOW) + assert entry == f"- **{DECISION}** Why: it needs no daemon (claude, 2026-09-06T07:02:11Z, source: issue #8)" + + +def test_entry_omits_what_was_not_given_and_collapses_whitespace() -> None: + entry = workflow.format_entry(" Use\n SQLite. ", why=None, agent="codex", source=None, now=NOW) + assert entry == "- **Use SQLite.** (codex, 2026-09-06T07:02:11Z)" + assert workflow.format_entry("x", why=" ", agent="", source="", now=NOW) == "- **x** (unknown, 2026-09-06T07:02:11Z)" + + +def test_an_empty_decision_is_refused() -> None: + with pytest.raises(workflow.WorkflowError, match="empty"): + workflow.format_entry(" \n ", why=None, agent="claude", source=None, now=NOW) + + +# --- placement ----------------------------------------------------------------- + + +def test_first_entry_opens_a_heading_after_the_template_header() -> None: + template = "# Decision Log\n\n\n" + out = workflow.insert_entry(template, "2026-09-06", "- **a**") + assert out == template + "\n## 2026-09-06\n\n- **a**\n" + + +def test_a_newer_day_goes_on_top_and_the_same_day_goes_first_under_its_heading() -> None: + text = "# Decision Log\n\n## 2026-09-05\n\n- **old**\n" + text = workflow.insert_entry(text, "2026-09-06", "- **a**") + assert text == "# Decision Log\n\n## 2026-09-06\n\n- **a**\n\n## 2026-09-05\n\n- **old**\n" + text = workflow.insert_entry(text, "2026-09-06", "- **b**") + assert text == "# Decision Log\n\n## 2026-09-06\n\n- **b**\n- **a**\n\n## 2026-09-05\n\n- **old**\n" + + +def test_placement_keeps_a_file_without_a_trailing_newline_or_blank_lines_tidy() -> None: + assert workflow.insert_entry("# Decision Log", "2026-09-06", "- **a**") == "# Decision Log\n\n## 2026-09-06\n\n- **a**\n" + out = workflow.insert_entry("# Decision Log\n## 2026-09-05\n- **old**\n", "2026-09-06", "- **a**") + assert out == "# Decision Log\n\n## 2026-09-06\n\n- **a**\n\n## 2026-09-05\n- **old**\n" + + +# --- capture ------------------------------------------------------------------- + + +def test_capture_appends_newest_first_with_provenance_and_keeps_the_mode(home: Path) -> None: + log = _log(home) + log.chmod(0o600) + first = workflow.capture(home, "demo", DECISION, why="no daemon", source="issue #8", agent="claude", now=NOW) + later = NOW + dt.timedelta(days=1) + second = workflow.capture(home, "demo", "Next day.", agent="codex", now=later) + text = log.read_text(encoding="utf-8") + assert text.index("## 2026-09-07") < text.index("## 2026-09-06") + assert first["entry"] in text and second["entry"] in text + assert first["written"] and first["path"] == str(log) and first["date"] == "2026-09-06" and first["agent"] == "claude" + assert stat.S_IMODE(log.stat().st_mode) == 0o600 + assert [p.name for p in log.parent.iterdir() if p.name.endswith(".tmp")] == [] + + +def test_capture_dry_run_composes_the_entry_and_writes_nothing(home: Path) -> None: + before = _log(home).read_bytes() + result = workflow.capture(home, "demo", DECISION, agent="claude", now=NOW, dry_run=True) + assert result["dry_run"] and not result["written"] and result["entry"].startswith(f"- **{DECISION}**") + assert _log(home).read_bytes() == before + + +def test_capture_touches_only_the_decision_log(home: Path) -> None: + memory = layout.project_memory_dir(home, "demo") + others = {p: p.read_bytes() for p in memory.iterdir() if p.is_file() and p.name != workflow.DECISION_FILE} + workflow.capture(home, "demo", DECISION, agent="claude", now=NOW) + assert {p: p.read_bytes() for p in others} == others + + +def test_capture_refuses_a_missing_tier_a_missing_log_and_a_symlink(home: Path, tmp_path: Path) -> None: + with pytest.raises(workflow.WorkflowError, match="init --project other"): + workflow.capture(home, "other", DECISION, agent="claude", now=NOW) + log = _log(home) + log.unlink() + with pytest.raises(workflow.WorkflowError, match="missing"): + workflow.capture(home, "demo", DECISION, agent="claude", now=NOW) + elsewhere = tmp_path / "elsewhere.md" + elsewhere.write_text("# Elsewhere\n", encoding="utf-8") + log.symlink_to(elsewhere) + with pytest.raises(workflow.WorkflowError, match="symlink"): + workflow.capture(home, "demo", DECISION, agent="claude", now=NOW) + assert elsewhere.read_text(encoding="utf-8") == "# Elsewhere\n" + + +@pytest.mark.parametrize("component", ["projects", "projects/demo", "projects/demo/memory"]) +def test_capture_refuses_a_linked_ancestor_and_leaves_its_target_untouched(home: Path, tmp_path: Path, component: str) -> None: + linked = home / component + outside = tmp_path / "outside" + linked.rename(outside) + linked.symlink_to(outside, target_is_directory=True) + before = {p: p.read_bytes() for p in outside.rglob("*") if p.is_file()} + with pytest.raises(workflow.WorkflowError, match="never writes through a link") as caught: + workflow.capture(home, "demo", DECISION, agent="claude", now=NOW) + assert str(linked) in str(caught.value) + assert {p: p.read_bytes() for p in outside.rglob("*") if p.is_file()} == before + assert list(outside.rglob(".*.tmp")) == [] + + +def test_capture_accepts_a_home_that_is_itself_a_link(home: Path, tmp_path: Path) -> None: + alias = tmp_path / "alias" + alias.symlink_to(home, target_is_directory=True) + result = workflow.capture(alias, "demo", DECISION, agent="claude", now=NOW) + assert result["written"] and result["entry"] in _log(home).read_text(encoding="utf-8") + + +def test_capture_keeps_the_group_and_other_bits_under_a_restrictive_umask(home: Path) -> None: + log = _log(home) + log.chmod(0o664) + previous = os.umask(0o077) + try: + workflow.capture(home, "demo", DECISION, agent="claude", now=NOW) + finally: + os.umask(previous) + assert stat.S_IMODE(log.stat().st_mode) == 0o664 + + +def test_capture_default_agent_comes_from_the_hook_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(workflow.DEFAULT_AGENT_ENV, "codex") + assert workflow.default_agent() == "codex" + monkeypatch.delenv(workflow.DEFAULT_AGENT_ENV) + monkeypatch.setenv("USER", "alice") + assert workflow.default_agent() == "alice" + monkeypatch.delenv("USER", raising=False) + assert workflow.default_agent() == "unknown" + + +# --- recall -------------------------------------------------------------------- + + +def test_recall_prints_the_files_in_manifest_order_with_their_content(home: Path) -> None: + workflow.capture(home, "demo", DECISION, agent="claude", now=NOW) + result = workflow.recall(home, project="demo", max_chars=100_000) + text = result["text"] + order = [entry["relative"] for entry in result["files"]] + manifest = startup.build_manifest(home, runtime="claude", project="demo") + assert order == [entry["relative"] for entry in manifest["files"]] + positions = [text.index(f"--- {relative} (") for relative in order] + assert positions == sorted(positions) + assert DECISION in text and "# Known Debt" in text and "# Org Memory" in text + assert result["content_injected"] is True and not result["truncated"] + assert result["excluded"] == manifest["excluded"] + assert f"Excluded by default: {', '.join(manifest['excluded'])}" in text + + +def test_recall_never_loads_the_excluded_directories(home: Path) -> None: + secret = "NEVER LOADED" + for relative in ("org-memory/events/e.md", "org-memory/debriefs/p/2026/09/20260906-a-1.md", "projects/demo/memory/archive/x.md"): + path = home / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret + "\n", encoding="utf-8") + assert secret not in workflow.recall(home, project="demo", max_chars=100_000)["text"] + + +@pytest.mark.parametrize("budget", [1, 5, 50, 200, 900]) +def test_recall_is_cut_at_the_budget_with_a_notice(home: Path, budget: int) -> None: + result = workflow.recall(home, project="demo", max_chars=budget) + assert len(result["text"]) <= budget and result["truncated"] + if budget > 150: + assert "recall cut at" in result["text"] + + +def test_recall_without_a_project_reads_the_org_files_and_warns(home: Path) -> None: + result = workflow.recall(home, project=None, max_chars=100_000) + assert [entry["tier"] for entry in result["files"]] == ["org"] * len(layout.ORG.files) + assert any("no project resolved" in warning for warning in result["warnings"]) + assert "no project" in result["text"].splitlines()[0] + + +def test_recall_names_a_missing_file_and_reads_the_rest(home: Path) -> None: + _log(home).unlink() + result = workflow.recall(home, project="demo", max_chars=100_000) + states = {entry["relative"]: entry["state"] for entry in result["files"]} + assert states["projects/demo/memory/decision_log.md"] == startup.MISSING + assert "--- projects/demo/memory/decision_log.md" not in result["text"] + assert "--- projects/demo/memory/known_debt.md" in result["text"] + assert any("decision_log.md: missing" in warning for warning in result["warnings"]) + + +# --- the shipped text ---------------------------------------------------------- + + +@pytest.mark.parametrize("runtime", sorted(SPECS)) +def test_workflow_text_is_rendered_from_the_shipped_template(runtime: str) -> None: + text = workflow.workflow_text(runtime) + assert text.startswith("---\nname: agent-memory\ndescription: ") + assert f"# agent-memory workflow for {runtime}" in text + assert f"agent-memory setup {runtime}" in text and f"--agent {runtime}" in text + assert "{runtime}" not in text + assert "agent-memory recall" in text and "agent-memory capture" in text + for excluded in ("archive/", "events/", "debriefs/"): + assert excluded in text + + +# --- the command line ---------------------------------------------------------- + + +def test_cli_capture_and_recall(home: Path, isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["capture", "--home", str(home), "--project", "demo", DECISION, "--why", "no daemon", "--agent", "claude", "--json"]) + assert rc == 0 + result = json.loads(capsys.readouterr().out) + assert result["written"] and result["project"] == "demo" and result["entry"].startswith(f"- **{DECISION}**") + assert main(["capture", "--home", str(home), "--project", "demo", "Plain output.", "--agent", "claude"]) == 0 + out = capsys.readouterr().out + assert "captured" in out and "Plain output." in out + assert main(["recall", "--home", str(home), "--project", "demo"]) == 0 + text = capsys.readouterr().out + assert DECISION in text and "Plain output." in text + assert main(["recall", "--home", str(home), "--project", "demo", "--json", "--max-chars", "300"]) == 0 + data = json.loads(capsys.readouterr().out) + assert data["content_injected"] and data["truncated"] and len(data["text"]) <= 300 + + +def test_cli_capture_needs_a_project(home: Path, isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["capture", "--home", str(home), DECISION]) == 2 + assert "no project resolved" in capsys.readouterr().err + + +def test_cli_capture_refusals_exit_one_with_the_reason(home: Path, isolated: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert main(["capture", "--home", str(home), "--project", "other", DECISION]) == 1 + assert "init --project other" in capsys.readouterr().err + assert main(["capture", "--home", str(home), "--project", "demo", " "]) == 1 + assert "empty" in capsys.readouterr().err + + +def test_cli_takes_the_project_and_home_from_the_binding(home: Path, isolated: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + repo = isolated / "repo" + repo.mkdir() + org.init(home, project="demo", repo=repo) + monkeypatch.chdir(repo) + monkeypatch.setenv(workflow.DEFAULT_AGENT_ENV, "codex") + assert main(["capture", DECISION, "--json"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["project"] == "demo" and result["home"] == str(home) and result["agent"] == "codex" + assert main(["recall", "--json"]) == 0 + data = json.loads(capsys.readouterr().out) + assert data["project"] == "demo" and DECISION in data["text"] + + +# --- the acceptance bar: two fresh sessions ----------------------------------------- + + +def _tool_on_path(tmp_path: Path) -> Path: + """A ``agent-memory`` command on PATH that runs this checkout's CLI with this interpreter.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + shim = bin_dir / "agent-memory" + shim.write_text(f'#!/usr/bin/env bash\nexec "{sys.executable}" -m agent_memory "$@"\n', encoding="utf-8") + shim.chmod(0o755) + return bin_dir + + +@pytest.mark.parametrize("runtime", sorted(SPECS)) +def test_two_fresh_sessions_recall_a_seeded_decision(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, runtime: str, capsys: pytest.CaptureFixture[str]) -> None: + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash is not available") + monkeypatch.delenv(ENV_HOME, raising=False) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + # A scratch repository: no git, no credentials, nothing but the binding setup writes. + scratch = tmp_path / "scratch" + scratch.mkdir() + home = tmp_path / "home" + assert main(["init", "--home", str(home), "--project", "demo", "--repo", str(scratch)]) == 0 + assert main(["setup", runtime, "--home", str(home), "--repo", str(scratch)]) == 0 + capsys.readouterr() + spec = SPECS[runtime] + wrapper = scratch / spec.workflow_file + assert wrapper.read_text(encoding="utf-8") == workflow.workflow_text(runtime) + assert not (scratch / ".git").exists() + + # Session 1: records the seeded decision through the workflow, from the repository. + monkeypatch.chdir(scratch) + env = {**os.environ, "PATH": f"{_tool_on_path(tmp_path)}{os.pathsep}{os.environ.get('PATH', '')}"} + env.pop(ENV_HOME, None) + env.pop(ENV_COMPAT_HOME, None) + env.pop(workflow.DEFAULT_AGENT_ENV, None) + one = subprocess.run( + ["agent-memory", "capture", "--agent", runtime, DECISION, "--why", "no daemon", "--source", "session 1"], + cwd=str(scratch), env=env, capture_output=True, text=True, check=False, + ) + assert one.returncode == 0, one.stderr + assert DECISION in _log(home).read_text(encoding="utf-8") + + # Session 2: the installed hook runs at session start and hands the manifest to the runtime ... + hook = subprocess.run([bash, spec.script_file], cwd=str(scratch), env=env, capture_output=True, text=True, check=False) + assert hook.returncode == 0, hook.stderr + context = hook.stdout + if runtime == startup.RUNTIME_CODEX: + context = json.loads(context)["hookSpecificOutput"]["additionalContext"] + assert "project demo" in context and "content is injected" in context + assert "projects/demo/memory/decision_log.md: readable" in context + # ... and the bounded read the workflow file prescribes returns the decision with its provenance. + two = subprocess.run(["agent-memory", "recall"], cwd=str(scratch), env=env, capture_output=True, text=True, check=False) + assert two.returncode == 0, two.stderr + assert DECISION in two.stdout and f"({runtime}, 2" in two.stdout and "source: session 1" in two.stdout + assert two.stdout.index("--- projects/demo/memory/decision_log.md") < two.stdout.index("--- org-memory/recent.md")