diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faa0b58..4229723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: ${{ matrix.python-version }} cache: pip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ddfbe4..2f5b5fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" cache: pip @@ -40,7 +40,7 @@ jobs: test "${RELEASE_TAG}" = "v${PACKAGE_VERSION}" - name: Upload release artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: python-package-distributions path: dist/ @@ -57,13 +57,13 @@ jobs: steps: - name: Download release artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: python-package-distributions path: dist/ - name: Publish distributions to PyPI with attestations - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: attestations: true @@ -78,7 +78,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download release artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: python-package-distributions path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e08ead..b4503e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.5 - 2026-11-02 + +### Added + +- An `agent-memory` skill teaches agents when to recall, capture, archive and sync, matched to its release ([skill](skills/agent-memory/README.md)). +- Python 3.14 is declared in the package classifiers; CI has tested it since 0.1.4 ([pyproject](pyproject.toml)). + +### Changed + +- `setup` no longer writes a workflow skill into the repository and leaves an existing copy as it is ([setup](docs/commands.md#setup)). + ## 0.1.4 - 2026-10-26 ### Fixed diff --git a/README.md b/README.md index 1e314ae..8f9cc4b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ 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. +and install the source's skills/agent-memory skill; 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 @@ -58,6 +58,9 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and agent-memory setup codex ``` + Then copy the source's [`skills/agent-memory/`][skill] into your agent's + skills directory; it teaches the workflow below. + 4. **Record now; recall next session** in this repository: ```bash @@ -116,10 +119,10 @@ and deliver. A plain `pull` updates a clean tree and reports unpushed commits. Conflicts abort the rebase and retain local commits. Keys and setup receipts stay local. Network and rebase operations time out after 30 seconds. -`setup` installs a SessionStart hook and memory workflow. `startup` lists +`setup` installs a SessionStart hook. `startup` lists metadata for the four project files, then the three curated org files; it -injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow -tells the agent to run `recall` for an 8,000-character bounded read. Raise +injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The +[`agent-memory` skill][skill] 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. @@ -150,6 +153,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set. | [`debrief write`][debrief-write] | Publish a session summary. | | [`event write`][event-write] | Publish an org-memory event. | +[skill]: https://github.com/kiloloop/agent-memory/tree/main/skills/agent-memory [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 diff --git a/docs/commands.md b/docs/commands.md index 4be9806..e464515 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -146,12 +146,11 @@ 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. +hook is ever installed. `setup` installs no skill: the workflow is taught by +the [`agent-memory` skill](../skills/agent-memory/README.md), installed +separately. Release 0.1.4 and earlier also wrote a workflow file +(`.claude/skills/agent-memory/SKILL.md`, `.agents/skills/agent-memory/SKILL.md`); +`setup` now leaves an existing copy as it is and no longer records it. ## Startup @@ -174,7 +173,7 @@ different home lends no project, and says so in the warnings. ## Capture and recall -`capture` and `recall` are the workflow that file describes. `capture "" +`capture` and `recall` are the workflow the skill 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 diff --git a/pyproject.toml b/pyproject.toml index cf31834..cb7e59c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development", ] @@ -55,6 +56,7 @@ include = [ "/Makefile", "/README.md", "/scripts", + "/skills", "/src", "/tests", ] diff --git a/scripts/check_installed_wheel.py b/scripts/check_installed_wheel.py index 2f2efab..bfc7887 100644 --- a/scripts/check_installed_wheel.py +++ b/scripts/check_installed_wheel.py @@ -5,7 +5,9 @@ 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; +1. create a throwaway venv and install the newest ``dist/*.whl`` with no extras, then the + pytest requirement the wheel's own ``dev`` extra declares (pyproject.toml stays the one + declaration of that bound); 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 @@ -29,6 +31,7 @@ import hashlib import json import os +import re import shutil import subprocess import sys @@ -47,6 +50,17 @@ ) ORG_FILES = ("recent.md", "decisions.md", "rules.md") PROJECT_FILES = ("project_facts.md", "decision_log.md", "open_threads.md", "known_debt.md") +DEV_EXTRA = re.compile(r"""extra\s*==\s*["']dev["']""") + + +def dev_requirement(declared: Sequence[str], name: str) -> str: + """Return the wheel's ``dev``-extra requirement for ``name``, marker stripped (``pytest<10,>=8``).""" + for entry in declared: + requirement, _, marker = entry.partition(";") + requirement = requirement.strip() + if DEV_EXTRA.search(marker) and re.match(rf"{re.escape(name)}(?![\w.-])", requirement, re.IGNORECASE): + return requirement + sys.exit(f"the wheel's dev extra declares no {name} requirement") def newest_wheel() -> Path: @@ -102,15 +116,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int: 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) + run([python, "-m", "pip", "install", "--quiet", wheel], 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] + declared = json.loads(probe.stdout) + runtime = [entry for entry in declared if "extra ==" not in entry] if runtime: sys.exit(f"the wheel declares runtime dependencies: {runtime}") + run([python, "-m", "pip", "install", "--quiet", dev_requirement(declared, "pytest")], env) foreign = run( [python, "-c", "import importlib.util as u; raise SystemExit(0 if u.find_spec('oacp') is None else 1)"], env, diff --git a/skills/agent-memory/README.md b/skills/agent-memory/README.md new file mode 100644 index 0000000..e001f3c --- /dev/null +++ b/skills/agent-memory/README.md @@ -0,0 +1,143 @@ +# agent-memory skill + +Teach a coding agent the `agent-memory` workflow: recall the project's +memory at session start, record the user's decisions as they are made, and +keep the memory home tidy, recorded and synced. + +## What it is + +The CLI does the work. The skill tells an agent which verb to run when, and +which uses to avoid: + +- recall before acting, and capture only what the user decided; +- archive supplementary files, never the four active ones; +- treat debriefs and events as append-only records; +- push deliberately at the end of a session, knowing that a pull on a home + with local changes, the session-start hook's included, commits and + delivers them too. + +It lives in the same repository as the CLI and ships in the same release, so +each copy describes the release it came from. + +The claim it makes, and the repository's tests check: a decision captured in +one session is in the next session's `recall`, while the session-start +manifest lists that file without its content. Archived files and org events +stay out of that read, and an active memory file cannot be archived. + +## What it looks like + +The output below is real. It was captured against a scratch home, with +`--home` and `--project` given explicitly: + +```console +$ agent-memory capture "Cache with SQLite." --why "no daemon to run" --source "PR 12" --agent claude --home /private/tmp/claude/agent-memory-skill/home --project demo +captured: /private/tmp/claude/agent-memory-skill/home/projects/demo/memory/decision_log.md (## 2026-11-01) +- **Cache with SQLite.** Why: no daemon to run (claude, 2026-11-01T03:28:50Z, source: PR 12) + +$ agent-memory startup --runtime claude --home /private/tmp/claude/agent-memory-skill/home --project demo +agent-memory startup (claude): home /private/tmp/claude/agent-memory-skill/home (flag), project demo (flag) +Project memory, read in this order (states are readability only; no content is injected): + 1. projects/demo/memory/project_facts.md: readable, 196 bytes, modified 2026-11-01T03:28:50Z + 2. projects/demo/memory/decision_log.md: readable, 205 bytes, modified 2026-11-01T03:28:50Z + 3. projects/demo/memory/open_threads.md: readable, 28 bytes, modified 2026-11-01T03:28:50Z + 4. projects/demo/memory/known_debt.md: readable, 240 bytes, modified 2026-11-01T03:28:50Z +Org memory, curated context; consult what governs the work before doing it (not read by default): + 5. org-memory/recent.md: readable, 389 bytes, modified 2026-11-01T03:28:50Z + 6. org-memory/decisions.md: readable, 287 bytes, modified 2026-11-01T03:28:50Z + 7. org-memory/rules.md: readable, 283 bytes, modified 2026-11-01T03:28:50Z +Excluded by default: org-memory/events/, org-memory/debriefs/, projects/demo/memory/archive/ + +$ agent-memory recall --home /private/tmp/claude/agent-memory-skill/home --project demo --max-chars 1100 +agent-memory recall: home /private/tmp/claude/agent-memory-skill/home, project demo; content follows in manifest order, cut at 1100 characters. + +--- projects/demo/memory/project_facts.md (196 bytes, modified 2026-11-01T03:28:50Z) --- +# Project Facts + + + + +--- projects/demo/memory/decision_log.md (205 bytes, modified 2026-11-01T03:28:50Z) --- +# Decision Log + + + +## 2026-11-01 + +- **Cache with SQLite.** Why: no daemon to run (claude, 2026-11-01T03:28:50Z, source: PR 12) + +--- projects/demo/memory/open_threads.md (28 bytes, modified 2026-11-01T03:28:50Z) --- +# Open Threads + +- None yet. + +--- projects/demo/memory/known_debt.md (240 bytes, modified 2026-11-01T03:28:50Z) --- +# Known Debt + +Use this file to track verified, unresolved project debt +[agent-memory: recall cut at 1100 characters; raise --max-chars or read the remaining files directly] + +$ agent-memory archive demo decision_log.md --home /private/tmp/claude/agent-memory-skill/home +agent-memory: error: cannot archive standard active memory file: decision_log.md +``` + +The last command exits `1`. The cut notice is the signal the skill tells an +agent to act on: raise `--max-chars` or read the rest directly. + +## Run it + +Install the release this skill ships in, then copy this directory into your +agent's skills directory: + +```bash +python -m pip install agent-memory-cli==0.1.5 +mkdir -p ~/.claude/skills && cp -R skills/agent-memory ~/.claude/skills/ # Claude Code +mkdir -p ~/.agents/skills && cp -R skills/agent-memory ~/.agents/skills/ # Codex +``` + +`agent-memory --version` should print `agent-memory 0.1.5`. In a repository, +`agent-memory setup claude` or `setup codex` installs the session-start hook +that pulls the home and prints the manifest. The skill teaches the rest. The +full procedure is in [`SKILL.md`](SKILL.md). + +## Choices worth knowing about + +- **Released with the CLI.** The skill names the release it describes in its + install line, and the repository's tests fail when that disagrees with the + package version. `agent-memory --version` is the check: an older CLI needs + that release installed, a newer one needs the skill from its release. +- **One skill, one name.** `agent-memory setup` in release 0.1.4 also writes a + shorter workflow skill named `agent-memory` into the repository + (`.claude/skills/agent-memory/SKILL.md`, `.agents/skills/agent-memory/SKILL.md`). + Later releases stop writing it but never delete an existing copy. A copy left + from an earlier setup has the same name as this skill and can hide it, so + delete that copy when you install this one. +- **The hook syncs; the skill teaches.** The session-start hook keeps doing the + pull, and on a home with local changes that pull commits and delivers them. + The skill adds no hook of its own; `push` stays a deliberate end-of-session + step. +- **Runtime-neutral.** Commands take `--agent ` and, for + `recall`, `--runtime codex` from Codex. The skill does not bake in a runtime + name. + +## What it cannot see + +- Whether a remembered decision is still right, or whether a fact is true. + `recall` prints files; it does not judge them. +- Anything outside the fixed read set: supplementary files, `archive/`, + `events/` and `debriefs/` are read only when an agent opens them by name. +- Search, synthesis, embeddings or indexes. The CLI has none. +- Memory files whose structure has drifted from the templates. The skill says + where things go, not how to repair a file. + +## Verify the skill + +`tests/test_skill.py` runs with the rest of the suite (`make test`). It fails +when `SKILL.md` or this README names a version other than the one the package +releases, or shows a command or option the CLI does not have. + +The behaviour the skill relies on is tested with the commands themselves: +capture then recall across two sessions and the recall exclusions in +`tests/test_workflow.py`, the content-free startup manifest and a session-start +pull that commits and delivers a local change in `tests/test_startup.py`, +archive refusal and round trip in `tests/test_archive.py`, and event records in +`tests/test_events.py`. diff --git a/skills/agent-memory/SKILL.md b/skills/agent-memory/SKILL.md new file mode 100644 index 0000000..aa3190c --- /dev/null +++ b/skills/agent-memory/SKILL.md @@ -0,0 +1,146 @@ +--- +name: agent-memory +description: Cross-session memory for coding agents through the agent-memory CLI. Use at session start to recall the project's bounded memory, when the user makes a decision a later session must not rediscover, when asked what was decided before, and when tidying, recording or syncing memory (archive, debrief and event writes, pull, push). +--- + +# agent-memory + +Memory is plain Markdown in a home directory that persists across sessions, +agents and machines. The `agent-memory` command reads and writes it. This +skill says which verb to run when, and what each one must not be used for. + +## Install and check + +This skill ships in the agent-memory release it describes. Install that +release: + +```bash +python -m pip install agent-memory-cli==0.1.5 +``` + +`uv tool install agent-memory-cli==0.1.5` works too. The distribution is +`agent-memory-cli`; the command it installs is `agent-memory`. + +Before relying on anything below, confirm the version: +`agent-memory --version` must print `agent-memory 0.1.5`. If it prints an +older version, install this one. If it prints a newer one, replace this skill +with the `skills/agent-memory/` directory from that release's tag. + +## Where memory lives + +- **Home.** It resolves from `--home`, then `$AGENT_MEMORY_HOME`, then + `$OACP_HOME`, then the nearest `.agent-memory.json` binding above the working + directory, then a workspace marker, then `~/agent-memory`. When a result + looks wrong, run `agent-memory status` first: it names the home and the rule + that chose it. +- **Project tier** (`projects//memory/`). Four active files: + `project_facts.md` (stable facts), `decision_log.md` (decisions, newest + first), `open_threads.md` (work in flight and its owner) and `known_debt.md` + (known problems). Retired files go in `archive/`. +- **Org tier** (`org-memory/`). `recent.md`, `decisions.md` and `rules.md` are + curated context shared across projects. `events/` and `debriefs/` hold + immutable records. + +Most notes belong to a project. Use the org tier only for what another +repository needs. + +## Session start: recall + +A repository set up with `agent-memory setup claude` or `setup codex` runs a +session-start hook. The hook pulls the home (see Sync for what that pull +publishes) and prints a manifest listing which +files to read, with their readability only. It injects no file content. Before +acting on project work, read the bounded context: + +```bash +agent-memory recall +``` + +It prints the four project files, then the three curated org files, cut at +8000 characters. From Codex, add `--runtime codex`. When the output reports a +cut, raise `--max-chars` or read the remaining files directly. `archive/`, +`events/` and `debriefs/` are never loaded. Open one of their files by name +only when the work needs it, never by reading the directory recursively. + +Cite a decision by its date heading and its text. Say that you read a file +only when you actually read it. + +## During work: capture decisions + +When the user decides something a later session must not rediscover, record +it once: + +```bash +agent-memory capture "" --why "" --source "" --agent +``` + +- Capture what the user decided, not what you propose. If you are not sure it + was decided, ask. +- Write one decision per entry. To change a decision, capture the new one. Never + edit or delete the old entry. +- `--dry-run` composes the entry and writes nothing. +- `capture` writes only `decision_log.md`. Edit the other active files by hand: + stable facts go in `project_facts.md`, work in flight in `open_threads.md`, + problems in `known_debt.md`. Close or pause a thread when its state changes. + +## Tidy: archive and restore + +`agent-memory archive ` moves a supplementary file from +`memory/` into `memory/archive/_`. +`agent-memory restore ` moves it back, but only into a +slot that is empty. The four active files are never archived. Run either with +`--dry-run` first. An archived file drops out of the session-start read. + +## Records: debriefs and events + +Both verbs append and never replace. A different record at the same path is a +collision (exit `2`), and an identical one is accepted unchanged. Use +`--dry-run` to see the record before writing it. + +- `agent-memory debrief write --project P --agent A --runtime R --session S + --started-utc T0 --ended-utc T1 --body-file F` publishes a session summary + under `org-memory/debriefs/`. The session id is 1–32 lowercase alphanumerics. +- `agent-memory event write --agent A --project P --type decision|event|rule + --slug S --body-file F` publishes one org-memory event under + `org-memory/events/`. + +Neither verb curates. Folding events into `recent.md`, `decisions.md` or +`rules.md` is a deliberate edit, made by whoever owns that context. + +## Sync: pull and push + +This section applies only to a home with sync enabled, through +`agent-memory enable --remote `; `status` shows whether it is. + +- **Pull** runs at session start through the hook (`startup --pull`). On a + clean home it only updates. On a home with local memory changes it also + commits the allowlisted changes under the runtime's name and delivers them + to the remote. By hand, `agent-memory pull` updates a clean home, and + `pull --agent ` does the same commit-and-deliver on a dirty one. +- **Push** is the deliberate end-of-session step, once threads and debt are + up to date: `agent-memory push --agent `. `setup` installs no push + hook, but the next session's start publishes whatever this session left + uncommitted. Treat every memory edit as headed for the remote. +- Both commit only allowlisted memory paths, and anything else in the home stays + as it is. A rebase conflict aborts and keeps the local commit: report the + paths it names and do not resolve them by guessing. A credential helper + blocked by a sandbox is reported as such; rerun outside the sandbox. + +## Set up and check health + +- `agent-memory init --home --project --repo .` creates the home + and binds the repository. Git-ignore `.agent-memory.json`. +- `agent-memory setup claude` or `setup codex` installs the session-start hook. + It does not install this skill. +- `agent-memory status` exits `1` when the tree is dirty or diverged, or a + prerequisite failed. `agent-memory doctor` exits `1` on an error row. Neither + repairs anything. Read the row and fix the cause rather than retrying. + +## Report + +After running a verb, report the command, the home and project it resolved, +the exit status, and any warning: a recall that was cut, a sync state other +than synced, or a conflict. + +The CLI does no search, synthesis, embedding or indexing. Recall reads a fixed +set of files. diff --git a/skills/agent-memory/agents/openai.yaml b/skills/agent-memory/agents/openai.yaml new file mode 100644 index 0000000..a49e10d --- /dev/null +++ b/skills/agent-memory/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Memory" + short_description: "Recall, capture and sync cross-session project memory" + default_prompt: "Use $agent-memory to recall this project's memory before we start." diff --git a/src/agent_memory/__init__.py b/src/agent_memory/__init__.py index f7495c6..5e0fd88 100644 --- a/src/agent_memory/__init__.py +++ b/src/agent_memory/__init__.py @@ -4,6 +4,6 @@ from .home import HomeError, HomeResolution, resolve_home -__version__ = "0.1.4" +__version__ = "0.1.5" __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"] diff --git a/src/agent_memory/setup/__init__.py b/src/agent_memory/setup/__init__.py index d258e50..5d80df4 100644 --- a/src/agent_memory/setup/__init__.py +++ b/src/agent_memory/setup/__init__.py @@ -16,15 +16,12 @@ 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} @@ -41,13 +38,10 @@ "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 index 580326a..0a516ca 100644 --- a/src/agent_memory/setup/claude.py +++ b/src/agent_memory/setup/claude.py @@ -8,8 +8,7 @@ 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. +for byte what it wrote; the kernel's envelope hook and anything custom stay. """ from __future__ import annotations @@ -26,7 +25,6 @@ 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, diff --git a/src/agent_memory/setup/codex.py b/src/agent_memory/setup/codex.py index 1112e8a..524c29d 100644 --- a/src/agent_memory/setup/codex.py +++ b/src/agent_memory/setup/codex.py @@ -10,8 +10,7 @@ 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. +to it and is never touched. """ from __future__ import annotations @@ -31,7 +30,6 @@ 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."}, diff --git a/src/agent_memory/setup/common.py b/src/agent_memory/setup/common.py index fc3339d..bf844ed 100644 --- a/src/agent_memory/setup/common.py +++ b/src/agent_memory/setup/common.py @@ -46,7 +46,7 @@ from pathlib import Path from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple -from .. import __version__, layout, workflow +from .. import __version__, layout RECEIPT_SCHEMA_VERSION = 1 SCRIPT_MODE = 0o755 @@ -68,18 +68,12 @@ 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, @@ -87,7 +81,7 @@ REMOVE_LEGACY_FILE, } ) -CONFLICT_ACTIONS = frozenset({SCRIPT_CONFLICT, SETTINGS_CONFLICT, WORKFLOW_CONFLICT}) +CONFLICT_ACTIONS = frozenset({SCRIPT_CONFLICT, SETTINGS_CONFLICT}) #: How each action reads in the report: (marker, done, planned). VERBS: Dict[str, Tuple[str, str, str]] = { @@ -106,10 +100,6 @@ 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. @@ -164,10 +154,6 @@ class RuntimeSpec: 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)} @@ -190,20 +176,6 @@ def known_template_digests(spec: RuntimeSpec) -> Tuple[str, ...]: 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.""" @@ -241,10 +213,6 @@ class Plan: 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]: @@ -284,7 +252,6 @@ def plan_setup( 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) @@ -299,8 +266,6 @@ def plan_setup( 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), ) @@ -318,8 +283,6 @@ def plan_setup( receipt_path, receipt, receipt_write, - workflow_write=flow.write, - workflow_regenerate=flow.regenerate, ) @@ -395,60 +358,6 @@ def _plan_script(spec: RuntimeSpec, repo: Path, steps: List[Step]) -> _ScriptPla 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) @@ -604,8 +513,6 @@ def _receipt( 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]: @@ -645,18 +552,6 @@ def _receipt( "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)) @@ -707,13 +602,6 @@ def apply_plan(plan: Plan) -> None: _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) @@ -763,7 +651,7 @@ def lines(result: Result) -> List[str]: 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.") + out.append("nothing to do: the hook is installed and in place.") return out diff --git a/src/agent_memory/templates/workflow/SKILL.md b/src/agent_memory/templates/workflow/SKILL.md deleted file mode 100644 index e98df2f..0000000 --- a/src/agent_memory/templates/workflow/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -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 index dedb0d3..aa0f470 100644 --- a/src/agent_memory/workflow.py +++ b/src/agent_memory/workflow.py @@ -11,9 +11,8 @@ 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. +When to run them is taught by the ``agent-memory`` skill in the repository's +``skills/`` directory; ``setup`` no longer writes a workflow file. """ from __future__ import annotations @@ -23,7 +22,6 @@ import re import secrets import stat as stat_mod -from importlib import resources from pathlib import Path from typing import Any, Dict, List, Optional @@ -32,7 +30,6 @@ 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+") @@ -42,15 +39,6 @@ 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 ------------------------------------------------------------------- diff --git a/tests/test_setup.py b/tests/test_setup.py index fcb5064..3e8081e 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -18,7 +18,6 @@ import shlex import shutil import subprocess -import stat import sys from pathlib import Path from typing import Dict, List @@ -36,8 +35,6 @@ run_setup, script_text, template_digest, - workflow_digest, - workflow_text, ) from agent_memory.setup.common import ( CHMOD_SCRIPT, @@ -54,11 +51,7 @@ 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" @@ -150,7 +143,7 @@ def _run_hook(spec: RuntimeSpec, repo: Path, env: Dict[str, str]) -> subprocess. 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] + assert _actions(result) == [WRITE_SCRIPT, CREATE_SETTINGS, REGISTER] script = repo / spec.script_file assert script.read_text(encoding="utf-8") == script_text(spec) assert script.stat().st_mode & 0o777 == 0o755 @@ -161,9 +154,7 @@ def test_fresh_install(spec: RuntimeSpec, repo: Path, home: Path) -> None: 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()) + assert [entry["path"] for entry in receipt["managed"]] == [spec.script_file, spec.settings_file] def test_no_push_hook_is_ever_installed(spec: RuntimeSpec, repo: Path, home: Path) -> None: @@ -180,7 +171,7 @@ def test_rerun_changes_no_byte(spec: RuntimeSpec, repo: Path, home: Path) -> Non 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 _actions(again) == [SCRIPT_IN_PLACE, REGISTERED] assert (_tree(repo), _tree(home)) == before @@ -200,7 +191,7 @@ def test_edited_script_is_a_named_conflict_and_kept(spec: RuntimeSpec, repo: Pat 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 _actions(result) == [SCRIPT_CONFLICT, REGISTERED] 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 @@ -216,7 +207,7 @@ def test_an_earlier_template_is_regenerated(spec: RuntimeSpec, repo: Path, home: _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] + assert _actions(result) == [REGENERATE_SCRIPT, CREATE_SETTINGS, REGISTER] script = repo / spec.script_file assert script.read_text(encoding="utf-8") == script_text(spec) assert script.stat().st_mode & 0o777 == 0o755 @@ -232,7 +223,7 @@ def test_symlinked_script_is_never_written_through(spec: RuntimeSpec, repo: Path script.symlink_to(shared) result = run_setup(spec, repo, home) - assert _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER, WRITE_WORKFLOW] + assert _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER] assert "symlink" in result.plan.steps[0].detail receipt = _load(result.plan.receipt_path) assert receipt["managed"][0]["symlink"] is True @@ -240,7 +231,7 @@ def test_symlinked_script_is_never_written_through(spec: RuntimeSpec, repo: Path 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 _actions(result) == [SCRIPT_CONFLICT, REGISTERED] 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() @@ -251,7 +242,7 @@ def test_dangling_script_symlink_holds_the_registration(spec: RuntimeSpec, repo: 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 _actions(result) == [SCRIPT_CONFLICT, CREATE_SETTINGS, REGISTRATION_HELD] 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" @@ -278,7 +269,7 @@ def test_symlinked_settings_file_is_not_edited(spec: RuntimeSpec, repo: Path, ho 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 _actions(result) == [WRITE_SCRIPT, SETTINGS_CONFLICT] 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() @@ -292,7 +283,7 @@ def test_symlinked_settings_file_is_not_edited(spec: RuntimeSpec, repo: Path, ho 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 _actions(result) == [WRITE_SCRIPT, SETTINGS_CONFLICT] assert settings.read_text(encoding="utf-8") == content @@ -315,19 +306,19 @@ def test_missing_repo_is_an_error(spec: RuntimeSpec, home: Path, tmp_path: Path) 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] + assert _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER] 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 _actions(again) == [SCRIPT_IN_PLACE, REGISTERED] 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 _actions(result) == [WRITE_SCRIPT, REGISTERED] assert _load(settings)["hooks"][spec.event] == [spec.entry()] @@ -342,7 +333,7 @@ def test_registration_inside_a_custom_entry_is_not_duplicated_or_edited(spec: Ru 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 _actions(result) == [WRITE_SCRIPT, REGISTERED] assert settings.read_text(encoding="utf-8") == original @@ -378,7 +369,6 @@ def test_fleet_settings_retire_the_legacy_hooks_and_keep_custom_entries(repo: Pa REGISTER, REMOVE_LEGACY_FILE, REMOVE_LEGACY_FILE, - WRITE_WORKFLOW, ] after = _load(settings) assert "SessionEnd" not in after["hooks"] @@ -503,7 +493,7 @@ def test_held_registration_leaves_the_legacy_hooks_in_place(repo: Path, home: Pa 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 _actions(result) == [SCRIPT_CONFLICT, REGISTRATION_HELD, KEEP_LEGACY_FILE] assert "legacy hooks stay" in result.plan.steps[1].detail assert settings.read_bytes() == before assert _commands(_load(settings), "SessionStart") == [PULL] @@ -532,7 +522,7 @@ def test_codex_entry_sits_beside_the_kernel_entry_and_retires_only_its_pull_flag 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] + assert _actions(result) == [WRITE_SCRIPT, STRIP_FLAG, REGISTER] after = _load(hooks_file) assert after["description"] == fixture["description"] kernel, custom, ours = after["hooks"]["SessionStart"] @@ -546,7 +536,7 @@ def test_codex_entry_sits_beside_the_kernel_entry_and_retires_only_its_pull_flag 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] + assert not again.plan.changed and _actions(again) == [SCRIPT_IN_PLACE, REGISTERED] def _codex_repo_with(repo: Path, command: str) -> Path: @@ -587,11 +577,11 @@ def test_codex_pull_flag_is_retired_only_from_one_simple_command(repo: Path, hom 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 _actions(result) == [WRITE_SCRIPT, KEEP_FLAG, REGISTER] 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 _actions(result) == [WRITE_SCRIPT, STRIP_FLAG, REGISTER] assert kernel_after == expected assert "--pull-memory" not in kernel_after or expected is None again = run_setup(CODEX, repo, home) @@ -614,9 +604,9 @@ def test_template_without_its_execute_bit_is_repaired_and_runs(spec: RuntimeSpec 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 + assert _actions(dry) == [CHMOD_SCRIPT, CREATE_SETTINGS, REGISTER] 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 _actions(result) == [CHMOD_SCRIPT, CREATE_SETTINGS, REGISTER] 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) @@ -625,7 +615,7 @@ def test_template_without_its_execute_bit_is_repaired_and_runs(spec: RuntimeSpec 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] + assert not again.plan.changed and _actions(again) == [SCRIPT_IN_PLACE, REGISTERED] def test_linked_template_without_its_execute_bit_is_held_not_chmodded(spec: RuntimeSpec, repo: Path, home: Path, tmp_path: Path) -> None: @@ -636,7 +626,7 @@ def test_linked_template_without_its_execute_bit_is_held_not_chmodded(spec: Runt 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 _actions(result) == [SCRIPT_CONFLICT, CREATE_SETTINGS, REGISTRATION_HELD] 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) == [] @@ -656,7 +646,7 @@ def test_owner_executable_template_is_left_at_its_mode(spec: RuntimeSpec, repo: 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 _actions(result) == [SCRIPT_IN_PLACE, CREATE_SETTINGS, REGISTER] assert script.stat().st_mode & 0o777 == 0o700 assert _load(result.plan.receipt_path)["managed"][0]["mode"] == "0700" @@ -732,7 +722,7 @@ def test_cli_setup_reports_lines_json_and_dry_run(spec: RuntimeSpec, repo: Path, 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 [step["action"] for step in payload["steps"]] == [WRITE_SCRIPT, CREATE_SETTINGS, REGISTER] 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: @@ -755,7 +745,7 @@ def test_receipt_records_what_was_installed(spec: RuntimeSpec, repo: Path, home: 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"] + script, settings = receipt["managed"] assert script == { "path": spec.script_file, "resolved": os.path.realpath(repo / spec.script_file), @@ -768,14 +758,6 @@ def test_receipt_records_what_was_installed(spec: RuntimeSpec, repo: Path, home: 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"] == [] @@ -791,57 +773,34 @@ def test_setup_receipts_never_sync(tmp_path: Path, git_env: None) -> None: assert ignored.returncode == 0 -# --- the workflow file beside the hook ------------------------------------------- - +# --- no workflow skill: the agent-memory skill in skills/ teaches the workflow ------- -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 +#: Where release 0.1.4 and earlier wrote a generated workflow skill, per runtime. +EARLIER_SKILL_FILES = { + "claude": ".claude/skills/agent-memory/SKILL.md", + "codex": ".agents/skills/agent-memory/SKILL.md", +} -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_setup_writes_no_skill_file(spec: RuntimeSpec, repo: Path, home: Path) -> None: + result = run_setup(spec, repo, home) + assert not os.path.lexists(repo / EARLIER_SKILL_FILES[spec.name]) + assert [path for path in _tree(repo) if "skills" in path] == [] + assert all("skills" not in step.path for step in result.plan.steps) -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) +def test_an_earlier_generated_skill_is_left_as_it_is(spec: RuntimeSpec, repo: Path, home: Path) -> None: + earlier = repo / EARLIER_SKILL_FILES[spec.name] + earlier.parent.mkdir(parents=True) + earlier.write_text("---\nname: agent-memory\n---\n# earlier generated workflow\n", encoding="utf-8") + before = _tree(repo)[EARLIER_SKILL_FILES[spec.name]] 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 + assert not result.plan.conflicts + assert all(step.path != EARLIER_SKILL_FILES[spec.name] for step in result.plan.steps) + assert _tree(repo)[EARLIER_SKILL_FILES[spec.name]] == before + receipt = _load(result.plan.receipt_path) + assert EARLIER_SKILL_FILES[spec.name] not in [entry["path"] for entry in receipt["managed"]] + assert not run_setup(spec, repo, home).plan.changed def test_setup_accepts_a_repository_without_git(spec: RuntimeSpec, home: Path, tmp_path: Path) -> None: @@ -849,4 +808,4 @@ def test_setup_accepts_a_repository_without_git(spec: RuntimeSpec, home: Path, t 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() + assert (scratch / spec.script_file).is_file() diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 0000000..fb1cb47 --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""The ``agent-memory`` skill stays true to the CLI it ships with. + +The skill lives in this repository and is released with the package, so it +names the release ``__version__`` is headed for, and every command and option it +shows an agent exists. What those commands do is tested with the commands +themselves: test_workflow, test_archive, test_events and test_startup. +""" + +from __future__ import annotations + +import argparse +import re +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from agent_memory import __version__ +from agent_memory.cli import build_parser + +SKILL = Path(__file__).resolve().parents[1] / "skills" / "agent-memory" +SKILL_FILES = ["SKILL.md", "README.md"] +VERSION_MENTION = re.compile(r"agent-memory(?:-cli==| )(\d+(?:\.\d+)+(?:\.dev\d+)?)") +FENCED = re.compile(r"^```[^\n]*\n(.*?)^```", re.DOTALL | re.MULTILINE) +INLINE = re.compile(r"`([^`]+)`") +COMMAND = re.compile(r"\bagent-memory\s+([a-z][a-z-]*)") +OPTION = re.compile(r"(? list[str]: + """Fenced blocks and inline code spans: the text an agent runs.""" + return FENCED.findall(text) + INLINE.findall(FENCED.sub("", text)) + + +def _subcommands(parser: argparse.ArgumentParser) -> Iterator[tuple[str, argparse.ArgumentParser]]: + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + yield from action.choices.items() + + +def _options(parser: argparse.ArgumentParser) -> set[str]: + found = set(parser._option_string_actions) + for _name, command in _subcommands(parser): + found |= _options(command) + return found + + +@pytest.mark.parametrize("name", SKILL_FILES) +def test_the_skill_names_the_release_it_ships_in(name: str) -> None: + release = re.sub(r"\.dev\d+$", "", __version__) + named = set(VERSION_MENTION.findall((SKILL / name).read_text(encoding="utf-8"))) + assert named == {release}, ( + f"skills/agent-memory/{name} names {sorted(named)}; this tree releases {release}. " + "Move the skill's version with __version__." + ) + + +@pytest.mark.parametrize("name", SKILL_FILES) +def test_the_skill_shows_only_commands_and_options_the_cli_has(name: str) -> None: + parser = build_parser() + code = _code((SKILL / name).read_text(encoding="utf-8")) + commands = {command for span in code for command in COMMAND.findall(span)} + options = {option for span in code for option in OPTION.findall(span)} + + assert commands and options + assert commands <= {command for command, _parser in _subcommands(parser)} + assert options <= _options(parser) diff --git a/tests/test_workflow.py b/tests/test_workflow.py index c3efd35..f2993c0 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -5,8 +5,8 @@ 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: +bounded read in manifest order with its budget and exclusions, 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. @@ -239,21 +239,6 @@ def test_recall_names_a_missing_file_and_reads_the_rest(home: Path) -> None: 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 ---------------------------------------------------------- @@ -327,8 +312,7 @@ def test_two_fresh_sessions_recall_a_seeded_decision(tmp_path: Path, monkeypatch 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 (scratch / spec.script_file).is_file() assert not (scratch / ".git").exists() # Session 1: records the seeded decision through the workflow, from the repository.