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 0000000..bd63e74 Binary files /dev/null and b/docs/images/project-memory.jpg differ 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")