From cad39558d0b5d8ef2001d25d17e29038e1761947 Mon Sep 17 00:00:00 2001 From: "kiloloop-release[bot]" <269344367+kiloloop-release[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:46:29 -0700 Subject: [PATCH] feat: OACP v0.5.0 --- CHANGELOG.md | 26 + QUICKSTART.md | 26 +- README.md | 23 +- SPEC.md | 5 +- docs/guides/doctor.md | 6 +- docs/guides/memory-context.md | 4 +- docs/protocol/cross_runtime_sync.md | 117 --- docs/protocol/message_signing.md | 4 +- docs/protocol/org_memory.md | 254 +++++-- docs/protocol/runtime_capabilities.md | 8 +- docs/protocol/session_init.md | 26 +- examples/demo-workspace/README.md | 9 +- oacp/cli.py | 67 +- pyproject.toml | 11 +- scripts/codex_session_init.py | 68 +- scripts/init_org_memory.py | 150 ---- scripts/memory_archive_common.py | 67 -- scripts/memory_cli.py | 193 ----- scripts/memory_sync.py | 584 --------------- scripts/oacp_doctor.py | 668 +----------------- scripts/preflight.py | 118 ++++ scripts/promote_to_archive.py | 106 --- scripts/restore_from_archive.py | 96 --- scripts/send_inbox_message.py | 4 +- scripts/setup_runtime.py | 315 +++++++-- scripts/write_event.py | 2 +- templates/org-memory/decisions.md | 5 - templates/org-memory/events/.gitkeep | 0 .../20260317-170120-example-api-convention.md | 11 - templates/org-memory/recent.md | 17 - templates/org-memory/rules.md | 5 - tests/conformance/memory_layout/README.md | 23 + .../canonical_memory_gitignore.txt | 9 + tests/conformance/memory_layout/layout.yaml | 71 ++ tests/conformance/org_memory/README.md | 22 - .../org_memory/cases/bad_layout/expected.yaml | 5 - .../2026/08/20260825-alice-abc12345.md | 14 - .../2026/08/20260825-alice-1f3a9c2b.md | 14 - .../2026/08/20260825-alice-ABCD.md | 14 - .../2026/08/20260825_alice_abc12345.md | 14 - .../2026/09/20260825-alice-def12345.md | 14 - .../demo-project/20260825-alice-abc12345.md | 14 - .../cases/empty_store/expected.yaml | 2 - .../empty_store/org-memory/debriefs/.gitkeep | 0 .../cases/missing_debriefs_dir/expected.yaml | 5 - .../missing_debriefs_dir/org-memory/recent.md | 1 - .../cases/staging_artifact/expected.yaml | 5 - .../.stage.20260825-alice-77xx88yy.md.a1b2c3 | 1 - .../2026/08/20260825-alice-1f3a9c2b.md | 14 - .../cases/valid_store/expected.yaml | 2 - .../valid_store/org-memory/debriefs/.gitkeep | 0 .../2026/08/20260825-bob-ops-9f00aa11.md | 14 - .../2026/08/20260825-alice-1f3a9c2b.md | 14 - .../2026/07/20260701-Alice_2.dev-00aa11bb.md | 14 - .../2026/12/20261203-bob-9e0d44aa.md | 14 - tests/test_codex_session_init.py | 79 ++- tests/test_init_org_memory.py | 46 -- tests/test_memory_archive.py | 445 ------------ tests/test_memory_layout_fixture.py | 198 ++++++ tests/test_memory_shim.py | 135 ++++ tests/test_memory_sync.py | 331 --------- tests/test_oacp_cli.py | 35 +- tests/test_oacp_doctor.py | 160 +---- tests/test_org_memory_doctor.py | 195 ----- tests/test_package_content.py | 105 ++- tests/test_preflight.py | 80 +++ tests/test_readme_commands.py | 4 +- tests/test_setup_runtime.py | 531 +++++++++++++- 68 files changed, 2064 insertions(+), 3575 deletions(-) delete mode 100644 docs/protocol/cross_runtime_sync.md delete mode 100644 scripts/init_org_memory.py delete mode 100644 scripts/memory_archive_common.py delete mode 100644 scripts/memory_cli.py delete mode 100644 scripts/memory_sync.py delete mode 100644 scripts/promote_to_archive.py delete mode 100644 scripts/restore_from_archive.py delete mode 100644 templates/org-memory/decisions.md delete mode 100644 templates/org-memory/events/.gitkeep delete mode 100644 templates/org-memory/events/20260317-170120-example-api-convention.md delete mode 100644 templates/org-memory/recent.md delete mode 100644 templates/org-memory/rules.md create mode 100644 tests/conformance/memory_layout/README.md create mode 100644 tests/conformance/memory_layout/canonical_memory_gitignore.txt create mode 100644 tests/conformance/memory_layout/layout.yaml delete mode 100644 tests/conformance/org_memory/README.md delete mode 100644 tests/conformance/org_memory/cases/bad_layout/expected.yaml delete mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md delete mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md delete mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md delete mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md delete mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md delete mode 100644 tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md delete mode 100644 tests/conformance/org_memory/cases/empty_store/expected.yaml delete mode 100644 tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep delete mode 100644 tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml delete mode 100644 tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md delete mode 100644 tests/conformance/org_memory/cases/staging_artifact/expected.yaml delete mode 100644 tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 delete mode 100644 tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md delete mode 100644 tests/conformance/org_memory/cases/valid_store/expected.yaml delete mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep delete mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md delete mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md delete mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md delete mode 100644 tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md delete mode 100644 tests/test_init_org_memory.py delete mode 100644 tests/test_memory_archive.py create mode 100644 tests/test_memory_layout_fixture.py create mode 100644 tests/test_memory_shim.py delete mode 100644 tests/test_memory_sync.py delete mode 100644 tests/test_org_memory_doctor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd52b6..8ac09a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-09-09 + +### Added + +- Memory-layout conformance fixture pinning the layout grammar and sync allowlist ([fixture](tests/conformance/memory_layout/README.md)). +- `oacp doctor` reports the installed `agent-memory` version and defers memory-home checks to it ([doctor guide](docs/guides/doctor.md)). +- Preflight and wheel guards fail when a kernel module or the wheel ships the memory engine ([lean kernel](docs/protocol/org_memory.md#conformance)). + +### Changed + +- The org-memory doc becomes the three-tier memory layout spec the kernel scaffolds against ([memory layout](docs/protocol/org_memory.md#layout)). +- `oacp memory` and `oacp org-memory` delegate to the `agent-memory` CLI until their removal in 0.5.2 ([README](README.md#commands)). +- `oacp setup` no longer installs memory hooks and retires the ones it generated; `agent-memory setup` replaces them ([quickstart](QUICKSTART.md)). +- `oacp session-init --pull-memory` delegates to `agent-memory`, reporting `disabled` when absent ([codex](docs/protocol/session_init.md#codex)). + +### Removed + +- `docs/protocol/cross_runtime_sync.md`; its durable-memory half moved to the layout spec ([layout](docs/protocol/org_memory.md#per-project-tier)). +- The bundled memory engine and org-memory doctor category, shipped as `agent-memory-cli` ([agent-memory](https://github.com/kiloloop/agent-memory)). +- `oacp doctor --memory`; the memory home is checked by `agent-memory doctor` ([doctor guide](docs/guides/doctor.md)). + +### Fixed + +- `oacp setup codex` regenerates its managed `SessionStart` hook but never creates one ([codex startup](docs/protocol/session_init.md#codex)). + ## [0.4.6] - 2026-09-05 ### Added @@ -843,6 +868,7 @@ The central debrief store is the headline change: every agent's full end-of-sess - Checkout step in github-release workflow job (#19) - Pre-release audit fixes: SHA-pinned actions, dangling doc refs (#15, #16) +[0.5.0]: https://github.com/kiloloop/oacp/compare/v0.4.6...v0.5.0 [0.4.6]: https://github.com/kiloloop/oacp/compare/v0.4.5...v0.4.6 [0.4.5]: https://github.com/kiloloop/oacp/compare/v0.4.4...v0.4.5 [0.4.4]: https://github.com/kiloloop/oacp/compare/v0.4.3...v0.4.4 diff --git a/QUICKSTART.md b/QUICKSTART.md index 8944187..c4894db 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -89,9 +89,14 @@ This creates or updates: - `.claude/agents/my-first-project.md` - `.claude/skills/` -- `.claude/hooks/oacp-memory-pull.sh` - `.claude/settings.json` +Memory sync and its startup hook are the memory tool's: install +[agent-memory](https://github.com/kiloloop/agent-memory) (`pip install +agent-memory-cli`) and run `agent-memory setup claude`. A regeneration of +`oacp setup` retires the memory hooks earlier kernels wrote, by exact command +and digest, and leaves anything you edited in place. + For other supported runtimes, use the same shape: ```bash @@ -101,20 +106,21 @@ oacp setup gemini --project my-first-project ``` Codex setup creates or merges `.codex/hooks.json` with one startup-only hook. -Review and trust it with `/hooks`; it runs memory pull and startup verification -sequentially. If hooks are unavailable, run the manual fallback: +Review and trust it with `/hooks`; it runs the startup verification. If hooks +are unavailable, run the manual fallback: ```bash -oacp session-init --pull-memory --project my-first-project +oacp session-init --project my-first-project ``` -Memory pull synchronizes files on disk. The init manifest names the protocol -files and four project memory files to read into context; org memory is -retrieved on demand. See [memory context](docs/guides/memory-context.md) for -retrieval and existing-installation guidance. +The init manifest names the protocol files and four project memory files to +read into context; org memory is retrieved on demand. Syncing the home is +`agent-memory pull` (its `setup codex` hook runs it at startup). See +[memory context](docs/guides/memory-context.md) for retrieval and +existing-installation guidance. Runtime setup does not install an automatic memory push. Publish durable memory -explicitly during wrap-up with `oacp memory push`. +explicitly during wrap-up with `agent-memory push`. Cursor support is scaffold-only until Cursor-owned rules land. Cursor sessions must set `OACP_RUNTIME=cursor` or pass `--from` explicitly when sending messages. @@ -226,7 +232,7 @@ checks the project workspace, inboxes, schemas, and agent status files. ## What's Next? - **Review loop** — Set up structured code review between agents. See [docs/protocol/review_loop.md](docs/protocol/review_loop.md). -- **Durable memory** — Learn how agents share knowledge across sessions. See [docs/protocol/cross_runtime_sync.md](docs/protocol/cross_runtime_sync.md). +- **Durable memory** — Learn how agents share knowledge across sessions and machines. See [docs/protocol/org_memory.md](docs/protocol/org_memory.md). - **Safety defaults** — Understand the baseline safety rules. See [docs/protocol/agent_safety_defaults.md](docs/protocol/agent_safety_defaults.md). - **Full protocol** — Read the complete specification in [SPEC.md](SPEC.md). - **Adoption guide** — Minimum, recommended, and full adoption paths. See [docs/guides/adoption.md](docs/guides/adoption.md). diff --git a/README.md b/README.md index fe2c8bf..8b879af 100644 --- a/README.md +++ b/README.md @@ -234,13 +234,13 @@ uv tool install . | `oacp inbox` | List pending inbox messages | | `oacp watch` | Emit inbox delta events for Monitor-friendly polling | | `oacp retention` | Prune project message history by age and count | -| `oacp memory` | Archive, restore, or sync memory files | +| `oacp memory` | Run agent-memory (sync, archive, restore); shim until 0.5.2 | | `oacp session-init` | Verify Codex startup inputs and emit SessionStart context | | `oacp setup` | Generate runtime-specific config files in a repo | | `oacp send` | Send a protocol-compliant inbox message | | `oacp key` | Generate and inspect message-signing keys | | `oacp trust` | Import, inspect, and revoke trust-root entries (catalog + pins) | -| `oacp org-memory` | Initialize org-level memory at $OACP_HOME/org-memory/ | +| `oacp org-memory` | Run agent-memory org (init); shim until 0.5.2 | | `oacp write-event` | Write an event to org-memory/events/ | | `oacp autonomy-outcome` | Record a human approval/decline in an autonomy audit | | `oacp autonomy-finalize` | Record checkpoints and terminal states in an autonomy audit | @@ -259,9 +259,9 @@ uv tool install . **`oacp watch`**: `--agent`, repeatable `--project`, `--all-projects`, `--json`, `--since` (default `now`), `--state-id ` for per-subscriber cursors, `--show-archived` -**`oacp doctor`**: `--fix` (auto-fix safe issues), `--memory`, `--json`, `-o/--output` +**`oacp doctor`**: `--fix` (auto-fix safe issues), `--json`, `-o/--output` -**`oacp memory`**: `init [--remote URL]`, `clone [--force]`, `pull`, `push`, `disable`, `archive `, `restore ` +**`oacp memory`** / **`oacp org-memory`**: exec shims for [agent-memory](https://github.com/kiloloop/agent-memory) (`pip install agent-memory-cli`): argv passes through, `--oacp-dir` becomes `--home`, `oacp memory init` runs `agent-memory enable`. Kept through 0.5.1, removed in 0.5.2; call `agent-memory` directly. @@ -341,15 +341,12 @@ $OACP_HOME/projects// └── workspace.json # Project metadata ``` -Optionally, `oacp org-memory init` creates org-level shared memory: - -``` -$OACP_HOME/org-memory/ -├── recent.md # Rolling summary, retrieved on demand -├── decisions.md # Org-wide decisions -├── rules.md # Standing conventions -└── events/ # Timestamped event entries -``` +Org-level shared memory and cross-machine sync are the memory tool's: +[agent-memory](https://github.com/kiloloop/agent-memory) (`pip install +agent-memory-cli`) scaffolds `$OACP_HOME/org-memory/` (`agent-memory org init`), +syncs the home (`enable`, `pull`, `push`), checks it (`agent-memory doctor`), and +installs the runtime startup hook (`agent-memory setup `). The layout it +implements is the kernel's [memory layout spec](docs/protocol/org_memory.md). Init loads the four active project memory files. Org memory is retrieved on demand; syncing it to disk does not load it into context. See diff --git a/SPEC.md b/SPEC.md index 5fb8871..d246de0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -23,7 +23,7 @@ documents ship inside the `oacp-cli` wheel. | [`docs/protocol/inbox_outbox.md`](docs/protocol/inbox_outbox.md) | Wire format including the signed `auth` trailer; directory layout; message types; lifecycle — a processed inbound message is archived to `inbox/archive/` by digest-checked, no-clobber move (never deleted), and an intake rejection is quarantined to `dead_letter/`; threading, broadcast, expiry, retention. | | [`docs/protocol/message_signing.md`](docs/protocol/message_signing.md) | Trust root and receiver pins, verify modes (`off` / `warn` / `enforce`), receiver audit stamping, policy-file signing, key management, rotation and revocation, signing conformance. | | [`docs/protocol/autonomy.md`](docs/protocol/autonomy.md) | Receiver autonomy: config and task profiles, the four-gate admission evaluator and hard stops, audit records (admission ledger, human outcomes, terminal finalization), threshold checkpoints and re-authorization, scope-envelope enforcement, continuation grants. | -| [`docs/protocol/org_memory.md`](docs/protocol/org_memory.md) | Org-level memory: directory structure, event file schema, the debrief store, permission model and lifecycle. | +| [`docs/protocol/org_memory.md`](docs/protocol/org_memory.md) | Memory layout: the per-project, org, and cross-machine tiers — paths, files, who writes what, the event and debrief schemas, the sync marker and allowlist. | ## Userland documents @@ -36,7 +36,6 @@ receiver must verify. | [`docs/protocol/task_negotiation.md`](docs/protocol/task_negotiation.md) | Propose / accept / counter-propose handshake for splitting work between agents. | | [`docs/protocol/multi_agent_shared_workspace.md`](docs/protocol/multi_agent_shared_workspace.md) | Shared-folder implementation → QA → deployment handoff with batched findings and signoff. | | [`docs/protocol/session_init.md`](docs/protocol/session_init.md) | Runtime-agnostic session-start sequence and failure handling. | -| [`docs/protocol/cross_runtime_sync.md`](docs/protocol/cross_runtime_sync.md) | Keeping context consistent across runtimes: durable memory, handoff messages, review artifacts. | | [`docs/protocol/runtime_capabilities.md`](docs/protocol/runtime_capabilities.md) | Static capability declarations, dynamic `status.yaml`, health-check contract, agent cards. | | [`docs/protocol/agent_profiles.md`](docs/protocol/agent_profiles.md) | Two-tier identity: global agent profiles and project-level agent cards. | | [`docs/protocol/agent_safety_defaults.md`](docs/protocol/agent_safety_defaults.md) | Baseline git, staging, inbox, credential, and scope rules every agent follows. | @@ -49,7 +48,7 @@ Guides — [`docs/guides/setup.md`](docs/guides/setup.md), [`versioning.md`](docs/guides/versioning.md), [`unified_skill_spec.md`](docs/guides/unified_skill_spec.md) — and the executable conformance fixtures under [`tests/conformance/`](tests/conformance/) -(autonomy, signing, intake, envelope) round out the set. Runtime-specific skills +(autonomy, signing, intake, envelope, memory layout) round out the set. Runtime-specific skills that operate the protocol live in the companion [oacp-skills](https://github.com/kiloloop/oacp-skills) repository. diff --git a/docs/guides/doctor.md b/docs/guides/doctor.md index e3caa4f..14bbbd0 100644 --- a/docs/guides/doctor.md +++ b/docs/guides/doctor.md @@ -27,6 +27,7 @@ Verifies that required and optional CLI tools are installed and reachable on `PA | `ruff` | No | Python linter (optional, used in preflight) | | `shellcheck` | No | Shell script linter (optional) | | `pyyaml` | No | PyYAML library (needed for YAML validation) | +| `agent-memory` | No | Memory tool (`agent-memory-cli`); the row reports its version and hands memory-home checks to `agent-memory doctor` | ### 2. Workspace @@ -103,6 +104,8 @@ $ oacp doctor [+] ruff — ruff 0.8.1 [-] shellcheck — not installed (optional) Install: brew install shellcheck + [-] agent-memory — not installed + Install: pip install agent-memory-cli [+] pyyaml — available No issues found. @@ -120,6 +123,7 @@ $ oacp doctor --project my-project [+] ruff — ruff 0.8.1 [-] shellcheck — not installed (optional) Install: brew install shellcheck + [+] agent-memory — agent-memory 0.1.0 (run agent-memory doctor) [+] pyyaml — available [+] Workspace @@ -181,6 +185,7 @@ The exit code reflects the overall result: | `pyyaml — not importable` | `pip install pyyaml` | | `ruff — not installed` | `pip install ruff` (optional, for linting) | | `shellcheck — not installed` | `brew install shellcheck` (optional, for shell script linting) | +| `agent-memory — not installed` | `pip install agent-memory-cli` (optional; `agent-memory doctor` then checks the memory home) | ### Workspace @@ -220,7 +225,6 @@ oacp doctor --project # full workspace + agent checks oacp doctor --json # machine-readable JSON output oacp doctor --project --json # full checks in JSON format oacp doctor --project --fix # auto-fix safe issues (missing inbox dirs, missing/stale status.yaml) -oacp doctor --memory # advisory checks for OACP_HOME memory git sync oacp doctor -o report.txt # save report to file ``` diff --git a/docs/guides/memory-context.md b/docs/guides/memory-context.md index 34b0c22..ac7d6a6 100644 --- a/docs/guides/memory-context.md +++ b/docs/guides/memory-context.md @@ -18,7 +18,7 @@ protocol files and those four project files, then emits a manifest. The agent still performs the ordered reads; verification does not inject their contents. Missing project files are reported under the existing degraded-init behavior. -`oacp memory pull` synchronizes the memory repository on disk, including org +`agent-memory pull` synchronizes the memory repository on disk, including org memory and its history. A successful pull does not add those files to model context. Keep any configured pull before project-memory reads so those reads use the refreshed files. Sync selection and context selection are independent; @@ -35,7 +35,7 @@ a hook that the project has disabled. others. Read applicable standing rules and decisions before the actions they govern, such as release, review authority, or architecture work. 2. If cross-machine sync is configured, the task needs current shared facts, - and local freshness is unknown, run `oacp memory pull`. A failed pull leaves + and local freshness is unknown, run `agent-memory pull`. A failed pull leaves freshness unknown; report that limitation before relying on affected facts. 3. Search the relevant explicit files with task identifiers and topic terms. For example, after confirming that `rules.md` exists: diff --git a/docs/protocol/cross_runtime_sync.md b/docs/protocol/cross_runtime_sync.md deleted file mode 100644 index a004617..0000000 --- a/docs/protocol/cross_runtime_sync.md +++ /dev/null @@ -1,117 +0,0 @@ -# Cross-Runtime Knowledge Sync Protocol - -## Problem - -Multi-agent workflows span different runtimes — Claude, Codex, Cursor, Gemini — each with its own context window, memory mechanism, and conversation state. Without an explicit sync protocol, agents lose context at handoff boundaries, duplicate decisions, or contradict prior work. - -Key challenges: - -- **Context window isolation**: Each agent session starts fresh. Prior decisions exist only in external artifacts. -- **Heterogeneous memory**: Claude uses `CLAUDE.md` + memory files; Codex uses `AGENTS.md` + memory; Gemini relies on conversation history and system prompts. -- **Handoff gaps**: When Agent A hands off to Agent B, critical context (why a decision was made, what was tried and failed) is often lost. - -## Sync Mechanisms - -The protocol defines three complementary sync mechanisms, ordered from most durable to most ephemeral. - -### 1. Durable Memory Files - -**Location**: `projects//memory/` - -| File | Purpose | Updated by | -|------|---------|------------| -| `project_facts.md` | Agent roles, repo structure, architecture, conventions | Any agent via the project's durable-memory promotion flow | -| `decision_log.md` | Timestamped decisions with rationale | Any agent via the project's durable-memory promotion flow | -| `open_threads.md` | Unresolved issues, blocked epics, cross-agent coordination | Any agent via the project's durable-memory promotion flow | -| `known_debt.md` | Verified unresolved debt that should persist across sessions | Any agent via the project's durable-memory promotion flow | - -These four files are the active working set. Other project files and -`memory/archive/` hold context retrieved when the task needs it; their presence -does not add them to the required init reads. Org memory is also retrieved on demand. - -These files are the **source of truth** for stable project knowledge. All runtimes read the four files in the order above at init. Only verified, stable outcomes should be written here. - -**Promotion flow**: Merge decisions contain a "Durable Memory Updates" section. Each implementation should provide a promotion mechanism that extracts approved entries from merge artifacts and appends them to the appropriate memory file, deduplicating against existing content. - -**Archive flow**: Users or coordinator agents may move non-standard memory files into `memory/archive/` for historical retention, then restore them back into `memory/` when needed again. Restoring a file does not add it to the required init read set. - -### 2. Handoff Messages with Context Keys - -**Location**: `agents//inbox/` - -When handing off work between agents (especially across runtimes), the sender includes `context_keys` in the handoff message — a concise summary of decisions made, artifacts produced, and open questions. - -See [Conversation Threading](inbox_outbox.md#conversation-threading) for field details. - -Context keys bridge the gap between the sender's rich conversational context and the recipient's cold start. They should include: - -- Decisions made and their rationale -- Artifacts produced (PRs, files, packets) -- Open questions or blockers -- What was tried and did not work - -### 3. Packet-Based Review Artifacts - -**Location**: `packets/review/`, `packets/findings/`, `merges/` - -Review packets, findings packets, and merge decisions form a structured audit trail. Agents entering a review cycle can read the packet history to understand what was reviewed, what issues were found, and how they were resolved. - -These artifacts are especially useful for cross-runtime sync because they follow a fixed schema that any runtime can parse. - -## Sync Points - -Agents synchronize knowledge at well-defined points in the workflow: - -| Sync Point | Action | Direction | -|------------|--------|-----------| -| **Session start** | Read `memory/project_facts.md`, `decision_log.md`, `open_threads.md`, `known_debt.md` (not `memory/archive/`) | Memory -> Agent | -| **Task completion** | Write stable outcomes to memory via merge decision + durable-memory promotion flow | Agent -> Memory | -| **Handoff** | Include `conversation_id` + `context_keys` in handoff message | Agent -> Agent | -| **Review cycle start** | Read relevant packet history | Packets -> Agent | -| **PR merge** | Update memory files if the change affects project conventions or architecture | Agent -> Memory | - -## Runtime-Specific Notes - -### Claude - -- **Reads**: `CLAUDE.md` (project-level instructions, auto-loaded), the four active project memory files at init, and other project/org context on demand -- **Writes**: Memory files via merge decisions + durable-memory promotion, handoff messages, review packets -- **Context mechanism**: `CLAUDE.md` is injected into every conversation. Memory files are read explicitly. -- **Tip**: Keep `CLAUDE.md` under 200 lines. Move detailed notes to memory files and reference them. - -### Codex - -- **Reads**: `AGENTS.md` (similar role to `CLAUDE.md`), `memory/` files -- **Writes**: Same artifacts as Claude -- **Context mechanism**: `AGENTS.md` is loaded at session start. Codex sessions are more ephemeral — handoff context keys are especially important. -- **Tip**: Codex works best with explicit, self-contained task descriptions. Include all necessary context in the handoff message rather than referencing external files. - -### Gemini - -- **Reads**: Conversation history (persistent within a session), memory files, system prompts -- **Writes**: Same artifacts as Claude and Codex -- **Context mechanism**: Gemini maintains richer in-session history but loses it across sessions. Handoff messages with context keys compensate. -- **Tip**: When Gemini hands off to another runtime, it should export conversation highlights into `context_keys` rather than assuming the recipient can access Gemini's conversation history. - -## Anti-Patterns - -Avoid these common mistakes when syncing knowledge across runtimes: - -| Anti-Pattern | Why It Fails | Do Instead | -|--------------|-------------|------------| -| Syncing raw conversation transcripts | Too verbose, runtime-specific formatting, wastes context window | Distill into `context_keys` or memory entries | -| Syncing ephemeral state (temp files, debug logs, partial results) | Clutters memory, confuses future agents | Only promote stable, verified outcomes | -| Assuming shared context | Agent B cannot read Agent A's conversation history | Always include context in handoff messages | -| Writing to memory too eagerly | Unverified or in-progress work pollutes the knowledge base | Wait until merge decision to promote | -| Skipping project memory reads at session start | Agent makes decisions contradicting prior work | Read the four active project memory files; retrieve org memory on demand | - -## Integration with durable-memory promotion - -Each project should provide a durable-memory promotion mechanism that: - -1. Scans merge decisions or equivalent terminal artifacts -2. Extracts entries from the "Durable Memory Updates" section -3. Appends new entries to `decision_log.md`, `open_threads.md`, `project_facts.md`, or `known_debt.md` -4. Deduplicates against existing content - -This ensures that knowledge flows from ephemeral review artifacts into durable memory that persists across sessions and runtimes, without requiring a specific helper script name. diff --git a/docs/protocol/message_signing.md b/docs/protocol/message_signing.md index 390abcf..03f0171 100644 --- a/docs/protocol/message_signing.md +++ b/docs/protocol/message_signing.md @@ -333,8 +333,8 @@ can never authenticate a message. - **Keys are per-machine and never leave `$OACP_HOME/keys/`.** They are never synced and never committed. The memory-sync allowlist structurally excludes `keys/` on the push side, and the canonical workspace - `.gitignore` carries an explicit `keys/` deny line that `oacp memory - init` and the doctor `root-gitignore` drift check propagate fleet-wide. + `.gitignore` carries an explicit `keys/` deny line that the memory tool + (`agent-memory enable`, and its doctor's drift check) propagates fleet-wide. - **The 0600 file keystore is the v0.4.0 floor, not the design.** Private key files are created `0600` under `0700` directories and loaded only after a mode check. The backend is pluggable by design: messages diff --git a/docs/protocol/org_memory.md b/docs/protocol/org_memory.md index bbcf140..43d8fca 100644 --- a/docs/protocol/org_memory.md +++ b/docs/protocol/org_memory.md @@ -1,13 +1,124 @@ -# Org-Level Memory Protocol +# Memory Layout Protocol ## Purpose -Shared, cross-project memory for multi-agent organizations. Agents across projects read org-wide decisions, conventions, and events from a single location. Complements per-project memory (`$OACP_HOME/projects//memory/`) — does not replace it. +Durable memory shared by every agent and runtime in an OACP home, laid out in +three tiers: + +| Tier | Where | Holds | +|---|---|---| +| Per-project | `projects//memory/` | The four-file active working set one project's agents read at session start, plus `archive/` | +| Org | `org-memory/` | Cross-project decisions, conventions, events, and the debrief store | +| Cross-machine | The home as a git repository | The two storage tiers above, carried between machines by an allowlisted sync | + +The kernel owns this layout: the paths, the files, who writes what, the marker +and ignore files, and the sync allowlist. `oacp init` scaffolds the per-project +tier against it. Memory *tooling* — the org-tier scaffold, capture, recall, +archive, the sync verbs, a memory doctor — is userland and lives in +[`agent-memory`](https://github.com/kiloloop/agent-memory) (`agent-memory-cli` +on PyPI), which implements this layout and links back here; this document +names no command surface. Runtime loading mechanics stay where they are: the +session-start read set is [Session Init](session_init.md#step-3-load-durable-memory) +Step 3, and org-memory retrieval policy is the +[memory context guide](../guides/memory-context.md). + +Lean-kernel admission: what every receiver must understand to share memory +with another is the layout — an entry written to +`projects//memory/decision_log.md` on one machine must be found at +that path by every other agent and machine. Everything else about memory (what +a good entry looks like, when to promote, how to search) is convention and +lives in guides and skills. + +## Layout + +The complete layout, relative to the OACP home (`$OACP_HOME`). `*` stands for +one project name; a trailing `/` marks a directory. This block is the finite +grammar of the layout: an implementation creates nothing else and a document +names nothing else. + +```oacp-memory-layout +.gitignore +.oacp-memory-repo +org-memory/ +org-memory/recent.md +org-memory/decisions.md +org-memory/rules.md +org-memory/events/ +org-memory/debriefs/ +projects/ +projects/*/memory/ +projects/*/memory/project_facts.md +projects/*/memory/decision_log.md +projects/*/memory/open_threads.md +projects/*/memory/known_debt.md +projects/*/memory/archive/ +projects/*/memory/.cache/ +``` + +`` follows the workspace project-name rule: any name that does not +start with `.` and contains no `/` or `\`. Everything else in the home is not +memory — `keys/` (the signing keystore, denied below), a project's `agents/` +and `packets/` trees, `state/`, and any other sibling stay on the machine that +wrote them. `.gitkeep` placeholders that scaffolding drops into empty +directories so they survive git are scaffolding, not layout. + +Scaffolding creates missing entries only. A file slot that is already +occupied — by a regular file, a directory, or a link, dangling included — is +never rewritten and never followed. An existing directory is entered as found, +a directory symlink included. A home that has drifted from this layout is +reported by tooling, not silently repaired. + +## Per-Project Tier + +**Location:** `projects//memory/`, created by `oacp init ` +with the rest of the project workspace. + +### The four files + +The active working set. Every runtime reads these four files, in this order, at +session start ([Session Init](session_init.md#step-3-load-durable-memory) +Step 3), and only verified, stable outcomes are written to them. + +| File | Holds | Written by | +|---|---|---| +| `project_facts.md` | Agent roles, repo structure, architecture, conventions | Any agent, via the project's durable-memory promotion flow | +| `decision_log.md` | Dated decisions with rationale. Append-only: a decision is superseded by a newer entry, never edited | Any agent, via the promotion flow | +| `open_threads.md` | Unresolved issues, blocked work, cross-agent coordination | Any agent, via the promotion flow | +| `known_debt.md` | Verified unresolved debt that should persist across sessions | Any agent, via the promotion flow | + +Scaffolding writes each file once, from a template, and never overwrites one +that exists. + +**Promotion flow.** Memory is written at stable points, not during work. Merge +decisions and equivalent terminal artifacts carry a "Durable Memory Updates" +section, and the project's promotion mechanism appends approved entries to the +matching file, deduplicating against existing content. Raw logs, long command +output, transcripts, and in-progress state never enter these files (see +[Durable Memory Promotion](multi_agent_shared_workspace.md#durable-memory-promotion)). + +### `archive/` + +`projects//memory/archive/` holds files retired from the active set — +supplementary notes a project accumulated, or an older working file replaced by +a newer one — for historical retention. Archived files are retrieved when a +task needs them; restoring one to `memory/` does not add it to the required +session-start reads, which stay the four files above. -## Directory Structure +### `.cache/` + +`projects//memory/.cache/` is reserved for local, regenerable state +(indexes, scratch) that tooling keeps beside the tier. It never syncs (see the +allowlist below) and is never a session-start read. + +## Org Tier + +**Location:** `org-memory/`, beside `projects/`, created by `agent-memory org +init`. Shared, cross-project memory: agents across projects read org-wide +decisions, conventions, and events from a single location. It complements +per-project memory and never replaces it. ``` -$OACP_HOME/org-memory/ +org-memory/ recent.md # rolling summary (~60K chars / ~15K tokens) decisions.md # topical: org-wide decisions (illustrative default) rules.md # topical: standing conventions (illustrative default) @@ -33,7 +144,7 @@ budget governs. Collapse older detail into topical files or history so the file remains a bounded rolling summary when retrieved. This is a curation budget, not a requirement or allowance to load that much context at startup. -## Event File Schema +### Event File Schema ```markdown --- @@ -50,7 +161,7 @@ supersedes: event/20260310-old-decision # optional — for decisions that over Short description of what happened and why it matters. ``` -### Required Fields +#### Required Fields | Field | Type | Description | |-------|------|-------------| @@ -60,15 +171,15 @@ Short description of what happened and why it matters. | `project` | string | Originating project | | `type` | enum | `decision`, `event`, or `rule` | -### Optional Fields +#### Optional Fields | Field | Type | Description | |-------|------|-------------| -| `source_ref` | string | Provenance ID for dual-write reconciliation | +| `source_ref` | string | Provenance ID for dual-write reconciliation; when the event was folded from a session debrief, the debrief-store filename stem (`--`) | | `related` | list | Cross-references to PRs, issues, or other events | | `supersedes` | string | Event path that this entry overrides | -### File Naming +#### File Naming Files are named `YYYYMMDD-HHMMSS-short-slug.md` where the timestamp provides sub-day ordering and the slug is a brief descriptor (lowercase, hyphen-separated). @@ -84,14 +195,15 @@ per session — instead of in per-project trees. Debriefs are the full-fidelity session record; events remain the filtered-outcome channel. The two are distinct artifact classes and neither substitutes for the other. -`oacp org-memory init` creates `debriefs/` (with a `.gitkeep` placeholder so -the empty directory survives git-based sync). `oacp doctor` checks the store -setup — directory presence, path layout, lingering staging artifacts, +The org-tier scaffold creates `debriefs/` (with a `.gitkeep` placeholder so +the empty directory survives git-based sync). The memory doctor checks the +store setup — directory presence, path layout, lingering staging artifacts, irregular entries — and never opens debrief files: content and format verification belong to the writer contract (read-back at publication) and to git history, not to the doctor. The kernel owns only this layout and schema; -the writer that produces debrief files is adopter tooling (for example, a -debrief skill script) — there is no kernel subcommand for writing debriefs. +the writer that produces debrief files is adopter tooling (for example, +agent-memory's debrief verb) — there is no kernel subcommand for writing +debriefs. ### Path and File Naming @@ -258,47 +370,93 @@ configuration is needed. - Debriefs are permanent history: individual files are never rewritten (see Append-Only Rule); retention beyond that is adopter-defined -## Integration Pattern (Cortex Reference Implementation) - -Cortex demonstrates the dual-pipeline pattern — same source data, two audiences. This is a reference implementation, not a protocol requirement. - -**Debrief step (write):** -- Debrief → cortex inbox (existing, for human) -- Debrief → `org-memory/debriefs/` (the canonical immutable session record — see Debrief Store) -- Curated outcome events → `org-memory/events/` (filtered derivatives, for agents — never the raw debrief; see Curation Guard) -- All three writes treated as a logical unit — retry/warn on partial failure -- `source_ref` in event frontmatter matches the debrief-store filename stem (`--`) for reconciliation - -**Sync step (curate):** -- Debriefs (read from `org-memory/debriefs/`) → SSOT + vault daily notes (existing, for human) -- Events → topical files + `recent.md` (new, for agents) -- Sync cross-references SSOT when curating topical files to prevent drift -- Sync is idempotent — handles duplicates/replays via `source_ref` + `created_at_utc` - -**Consistency model:** Eventual, not strong. The pipelines may temporarily diverge. `source_ref` enables reconciliation against the debrief-store record. Adopter failure semantics (retryable partial failure, blocked debrief, or acceptable degraded mode) apply to the inbox and event writes; the debrief-store write is required by the Debrief Store section, and a store write that ultimately fails is a reported failure to retry, never an accepted degraded mode. - -## v0.2 Scope - -1. Format spec (directory structure, frontmatter schema, naming convention) -2. CLI: `oacp org-memory init` (scaffold directory, including `debriefs/`) and `oacp write-event` (create event files) -3. Agents write their full session debrief to `org-memory/debriefs/` (via adopter tooling — see Debrief Store) and curated outcome events during debrief -4. Agents retrieve relevant sections of topical files and `recent.md` for org context on demand -5. Coordinator maintains topical files during sync - -## v0.3+ +## Cross-Machine Tier + +Optional. A home that syncs is a plain git repository at the home root with +one remote; the layout does not change and no server is involved. The two +storage tiers are what syncs, selected by an allowlist; every other path in +the home stays on the machine that wrote it. What an implementation does with +the allowlist — commit, push, fast-forward pull, refuse to merge — is the +tool's contract, not the kernel's: see agent-memory's +[sync commands](https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#sync). + +### The marker + +`.oacp-memory-repo` at the home root marks a home whose memory syncs. Presence +is the whole signal: tooling and startup hooks that pull memory check for the +file and do nothing when it is absent, so removing it disables sync locally +without touching the repository or its history. Its content is informational. +The name is a compatibility contract with every existing home; keep it +verbatim. Syncing is opt-in, so scaffolding a home never creates the marker. + +### The sync allowlist + +`.gitignore` at the home root carries the canonical allowlist, byte for byte: + +```gitignore +* +!*/ +!.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/ +``` -- Agent write access to topical files (with schema validation) -- `recent.md` auto-generation from topical files + recent events -- Search/discovery tooling (BM25 or similar) -- Structured `id` field on events for cross-referencing +Line by line: + +- **Deny everything, then re-allow directories** (`*`, `!*/`) so the tier + patterns below can reach into the tree. +- **The synced set** is the ignore file itself, the marker, `org-memory/**` + in full (the debrief store included), and `projects/*/memory/**` for every + project. +- **`projects/*/memory/.cache/` never syncs** — the one excluded subtree + inside a tier. +- **`keys/` never syncs.** The signing keystore + ([key management](message_signing.md#key-management)) is denied last, after + every allow rule, so the deny wins even if the allowlist is widened above + it. + +### Which paths sync + +The allowlist is also a predicate over home-relative paths, and the predicate, +not the ignore file, is what a sync engine trusts: + +- `.gitignore` and `.oacp-memory-repo` are allowed. +- A path with any component named `keys` is denied, at any depth, whatever + the ignore file says. +- A path strictly inside a tier directory (`org-memory/…`, + `projects//memory/…`) is allowed unless its first component below + the tier is one of that tier's unsynced names. The project tier excludes + `.cache`; the org tier excludes nothing, so `org-memory/.cache/…` syncs. + The tier directory itself is not a path inside it. +- Every other path is denied. + +The tier directories an engine may stage are enumerated in allowlist order: +`org-memory/` first, then each existing `projects//memory/` in +project-name order. + +## Conformance + +The layout ships as data. `tests/conformance/memory_layout/layout.yaml` +carries the same entry set as the Layout block above, and +`canonical_memory_gitignore.txt` beside it is the allowlist byte for byte. +`tests/test_memory_layout_fixture.py` holds this document, the fixture, and +the kernel's project-tier scaffold to one another in both directions, and +derives the allowlist's rule lines from the fixture's tiers and never-synced +names so the two fixture files cannot disagree. An implementation vendors the +two fixture files and asserts its own layout table, ignore text, and org-tier +scaffold against them the same way. ## Design Rationale | Alternative | Why not | |---|---| | Single monolithic file | Wastes tokens, no progressive disclosure | -| Events only (Codex pattern) | Optimizes for writing, weak for reading — "What's our API convention?" shouldn't require scanning 50 event files | -| Topical only (Iris pattern) | No low-friction write path for agents — event files require only frontmatter, not schema knowledge | +| Events only | Optimizes for writing, weak for reading — "What's our API convention?" shouldn't require scanning 50 event files | +| Topical only | No low-friction write path for agents — event files require only frontmatter, not schema knowledge | | Inheritance model | Flat merge is simpler, no parent/child override complexity | The hybrid (topical + events) gives agents a fast read path (topical files) and a fast write path (events/), with coordinator curation bridging the two. diff --git a/docs/protocol/runtime_capabilities.md b/docs/protocol/runtime_capabilities.md index a59b7d8..743042b 100644 --- a/docs/protocol/runtime_capabilities.md +++ b/docs/protocol/runtime_capabilities.md @@ -173,9 +173,11 @@ The `oacp doctor` command validates environment and workspace health. Every chec ### Runtime startup and session telemetry Runtime-specific startup owns `status.yaml`. For Codex, `oacp setup codex` -registers a `SessionStart` handler that runs memory pull and `oacp session-init` -sequentially after the user reviews and trusts it with `/hooks`. The manual -fallback is `oacp session-init --pull-memory --project `. +registers a `SessionStart` handler that runs `oacp session-init` after the user +reviews and trusts it with `/hooks`; memory pull is the memory tool's own hook +(`agent-memory setup codex`). Registration regenerates the managed handler only — +an existing hooks file carrying no managed entry is never given one. The manual +fallback is `oacp session-init --project `. Memory pull synchronizes disk files. Init names the required protocol and project-memory reads; org-memory retrieval is on demand. See diff --git a/docs/protocol/session_init.md b/docs/protocol/session_init.md index 8d933bd..a0a1072 100644 --- a/docs/protocol/session_init.md +++ b/docs/protocol/session_init.md @@ -162,10 +162,11 @@ This section provides guidance for per-runtime implementations. Steps 1-2 are handled automatically by Claude Code (CLAUDE.md loading). Step 3 requires explicit file reads or auto-memory. Step 4 uses `/check-inbox`. Step 5 is automatic (skill discovery). Step 6 requires a startup hook or explicit script call. -`oacp setup claude` registers the marker-gated memory pull at `SessionStart`. -Pull synchronizes files on disk; it does not load org memory into context. -It intentionally does not publish memory at `SessionEnd`; wrap-up owns the -single explicit `oacp memory push` path. Claude status reporting remains an +The memory tool registers the marker-gated memory pull at `SessionStart` +(`agent-memory setup claude`); `oacp setup claude` retires the pull hook earlier +kernels wrote. Pull synchronizes files on disk; it does not load org memory into +context. Nothing publishes memory at `SessionEnd`; wrap-up owns the single +explicit `agent-memory push` path. Claude status reporting remains an explicit runtime responsibility, and so is any session telemetry beyond `status.yaml`: the kernel ships no reference implementation for it, and runtime-kept telemetry is never a `status.yaml` writer. @@ -174,9 +175,13 @@ runtime-kept telemetry is never a `status.yaml` writer. Steps 1-2 are handled by AGENTS.md loading. Running `oacp setup codex --project ` installs one repo-local `SessionStart` handler in -`.codex/hooks.json`. After the user reviews and trusts it with `/hooks`, the -handler sequentially pulls shared memory to disk and runs `oacp session-init`. -No Codex `SessionEnd` hook is installed. +`.codex/hooks.json`. Setup regenerates the handler it owns; it never adds one to +a hooks file that has none, so a file kept deliberately without a managed entry +is left byte-identical and reported as `no managed entry; skipped`. After the +user reviews and trusts it with `/hooks`, the +handler runs `oacp session-init`; it does not pull memory. Syncing shared +memory to disk is the memory tool's own startup hook, installed separately by +`agent-memory setup codex`. No Codex `SessionEnd` hook is installed. The init command verifies that protocol and the four project memory files are readable, updates `status.yaml`, and emits bounded developer context naming the required @@ -185,9 +190,12 @@ Org-memory content is not read by this verification or included in its manifest. Use this manual fallback when the project hook is unavailable or untrusted: ```bash -oacp session-init --pull-memory --project +oacp session-init --project ``` +`--pull-memory` runs `agent-memory pull --home ` first when the tool is +on PATH and reports the pull as `disabled` with the install hint when it is not. + The script emits a deterministic acknowledgement payload suitable for first-response confirmation: ```text @@ -219,7 +227,7 @@ Steps 1-2 are handled by system prompts and `.agent/rules/`. Steps 3-6 can be im - **Safety Defaults**: `docs/protocol/agent_safety_defaults.md` — baseline safety rules loaded at Step 1 - **Inbox Protocol**: `docs/protocol/inbox_outbox.md` — message format for Step 4 - **Runtime Capabilities**: `docs/protocol/runtime_capabilities.md` — status schema and capability keys for Step 6 -- **Cross-Runtime Sync**: `docs/protocol/cross_runtime_sync.md` — durable memory files loaded at Step 3 +- **Memory Layout**: `docs/protocol/org_memory.md` — the per-project memory files loaded at Step 3 - **Session telemetry**: runtime-owned; the kernel ships no reference implementation — see `docs/protocol/runtime_capabilities.md` (Runtime startup and session telemetry) diff --git a/examples/demo-workspace/README.md b/examples/demo-workspace/README.md index e93ce0b..dceca48 100644 --- a/examples/demo-workspace/README.md +++ b/examples/demo-workspace/README.md @@ -111,8 +111,9 @@ event it triggered; `org-memory/recent.md` points down at the project's ## Format reference -- Event frontmatter schema, naming, and the promotion lifecycle: +- The layout of both tiers, event frontmatter schema, naming, and the + promotion lifecycle: [`docs/protocol/org_memory.md`](../../docs/protocol/org_memory.md) -- Blank scaffolds: `oacp org-memory init` (org level) and `oacp init - ` (project level — `memory/` files are created by the init - script). +- Blank scaffolds: `agent-memory org init` (org level, from the memory + tool) and `oacp init ` (project level — `memory/` files are + created by the init script). diff --git a/oacp/cli.py b/oacp/cli.py index f7e4341..f93bc72 100644 --- a/oacp/cli.py +++ b/oacp/cli.py @@ -6,10 +6,12 @@ from contextlib import nullcontext from importlib import resources +import os from pathlib import Path import runpy +import shutil import sys -from typing import Optional, Sequence +from typing import Dict, List, Optional, Sequence from oacp import __version__ @@ -25,13 +27,13 @@ inbox List pending inbox messages watch Emit inbox delta events for Monitor-friendly polling retention Prune project message history by age and count - memory Archive, restore, or sync memory files + memory Run agent-memory (sync, archive, restore); shim until 0.5.2 session-init Verify Codex startup inputs and emit SessionStart context setup Generate runtime-specific config files in a repo send Send a protocol-compliant inbox message key Generate and inspect message-signing keys trust Import, inspect, and revoke trust-root entries (catalog + pins) - org-memory Initialize org-level memory at $OACP_HOME/org-memory/ + org-memory Run agent-memory org (init); shim until 0.5.2 write-event Write an event to org-memory/events/ autonomy-outcome Record a human approval/decline in an autonomy audit autonomy-finalize Record checkpoints and terminal states in an autonomy audit @@ -47,15 +49,14 @@ oacp inbox my-project --agent claude oacp watch --agent claude --project my-project --json oacp retention my-project --dry-run - oacp memory init --remote git@github.com:/oacp-memory.git - oacp memory archive my-project research_notes.md - oacp session-init --pull-memory --project my-project + oacp memory archive my-project research_notes.md # runs: agent-memory archive ... + oacp session-init --project my-project oacp setup claude --project my-project oacp send my-project --to iris --type notification --subject "Done" --body "Completed" oacp key gen --agent claude oacp trust import /path/to/.pub.json --project my-project --agent claude oacp trust revoke --project my-project --agent claude - oacp org-memory init + oacp org-memory init # runs: agent-memory org init oacp write-event --agent claude --project my-project --type decision --slug api-convention --body "Use REST for public APIs" oacp autonomy-outcome /path/to/audit.yaml --decision approved oacp autonomy-finalize /path/to/audit.yaml --final-state done --started-at 2026-08-30T10:05:00Z --actual-files-touched 3 @@ -73,13 +74,11 @@ "inbox": "oacp_inbox.py", "watch": "oacp_watch.py", "retention": "retention.py", - "memory": "memory_cli.py", "session-init": "codex_session_init.py", "setup": "setup_runtime.py", "send": "send_inbox_message.py", "key": "key_cli.py", "trust": "trust_cli.py", - "org-memory": "init_org_memory.py", "write-event": "write_event.py", "autonomy-outcome": "record_autonomy_outcome.py", "autonomy-finalize": "finalize_autonomy_record.py", @@ -122,7 +121,57 @@ def _run_script(script_name: str, argv: Sequence[str]) -> int: sys.path[:] = old_sys_path +# The memory engine lives in the `agent-memory` tool (`agent-memory-cli` on +# PyPI). `oacp memory …` and `oacp org-memory …` delegate to it by exec: argv +# passes through, `--oacp-dir X` becomes `--home X`, and `oacp memory init` +# maps to `agent-memory enable` (the tool's own `init` scaffolds a home +# without git; `enable` is what the kernel's `init` did). The kernel bundles +# no fallback engine. This shim lasts through 0.5.1 and is removed in 0.5.2. +MEMORY_TOOL = "agent-memory" +MEMORY_TOOL_DISTRIBUTION = "agent-memory-cli" +DELEGATED_COMMANDS: Dict[str, Sequence[str]] = { + "memory": (), + "org-memory": ("org",), +} +_DELEGATED_VERBS: Dict[str, Dict[str, str]] = {"memory": {"init": "enable"}} +_HOME_FLAG, _LEGACY_HOME_FLAG = "--home", "--oacp-dir" + + +def delegated_argv(command: str, argv: Sequence[str]) -> List[str]: + """Return the `agent-memory` argv for an `oacp ` call.""" + rest = list(argv) + verbs = _DELEGATED_VERBS.get(command, {}) + if rest and rest[0] in verbs: + rest[0] = verbs[rest[0]] + rewritten: List[str] = [] + for arg in rest: + if arg == _LEGACY_HOME_FLAG: + rewritten.append(_HOME_FLAG) + elif arg.startswith(_LEGACY_HOME_FLAG + "="): + rewritten.append(_HOME_FLAG + arg[len(_LEGACY_HOME_FLAG) :]) + else: + rewritten.append(arg) + return [MEMORY_TOOL, *DELEGATED_COMMANDS[command], *rewritten] + + +def _delegate(command: str, argv: Sequence[str]) -> int: + tool = shutil.which(MEMORY_TOOL) + if tool is None: + print( + f"ERROR: `oacp {command}` delegates to `{MEMORY_TOOL}`, which is not on PATH; " + f"install it with `pip install {MEMORY_TOOL_DISTRIBUTION}`.", + file=sys.stderr, + ) + return 127 + sys.stdout.flush() + sys.stderr.flush() + os.execvp(tool, delegated_argv(command, argv)) + return 0 # pragma: no cover - execvp does not return + + def _dispatch(command: str, argv: Sequence[str]) -> int: + if command in DELEGATED_COMMANDS: + return _delegate(command, argv) script_name = SCRIPT_NAMES.get(command) if script_name is None: print(f"ERROR: unknown command '{command}'", file=sys.stderr) diff --git a/pyproject.toml b/pyproject.toml index 93f51fe..d4a5509 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oacp-cli" -version = "0.4.6" +version = "0.5.0" description = "Open Agent Coordination Protocol CLI for file-based multi-agent workflows" readme = "README.md" license = "Apache-2.0" @@ -87,13 +87,7 @@ packages = ["oacp"] "scripts/oacp_inbox.py" = "oacp/_scripts/oacp_inbox.py" "scripts/oacp_watch.py" = "oacp/_scripts/oacp_watch.py" "scripts/retention.py" = "oacp/_scripts/retention.py" -"scripts/memory_cli.py" = "oacp/_scripts/memory_cli.py" -"scripts/memory_sync.py" = "oacp/_scripts/memory_sync.py" -"scripts/memory_archive_common.py" = "oacp/_scripts/memory_archive_common.py" -"scripts/promote_to_archive.py" = "oacp/_scripts/promote_to_archive.py" -"scripts/restore_from_archive.py" = "oacp/_scripts/restore_from_archive.py" "scripts/setup_runtime.py" = "oacp/_scripts/setup_runtime.py" -"scripts/init_org_memory.py" = "oacp/_scripts/init_org_memory.py" "scripts/write_event.py" = "oacp/_scripts/write_event.py" "scripts/check_quality_gate.py" = "oacp/_scripts/check_quality_gate.py" "scripts/init_packet.sh" = "oacp/_scripts/init_packet.sh" @@ -112,9 +106,6 @@ packages = ["oacp"] "docs/guides/memory-context.md" = "oacp/guides/memory-context.md" "templates/inbox_message.template.yaml" = "oacp/_templates/inbox_message.template.yaml" "templates/runtime_capabilities.yaml" = "oacp/_templates/runtime_capabilities.yaml" -"templates/org-memory/recent.md" = "oacp/_templates/org-memory/recent.md" -"templates/org-memory/decisions.md" = "oacp/_templates/org-memory/decisions.md" -"templates/org-memory/rules.md" = "oacp/_templates/org-memory/rules.md" "templates/agent_card.template.yaml" = "oacp/_templates/agent_card.template.yaml" "templates/agent_profile.template.yaml" = "oacp/_templates/agent_profile.template.yaml" "templates/receiver_config.template.yaml" = "oacp/_templates/receiver_config.template.yaml" diff --git a/scripts/codex_session_init.py b/scripts/codex_session_init.py index 0281599..b9e1326 100644 --- a/scripts/codex_session_init.py +++ b/scripts/codex_session_init.py @@ -8,6 +8,8 @@ import argparse import json import os +import shutil +import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -331,6 +333,16 @@ def run_session_init( } +# The memory engine is the `agent-memory` tool (`agent-memory-cli` on PyPI). +# The startup pull runs it as a subprocess when it is on PATH and reports +# `disabled` with the install hint when it is not; the kernel bundles no +# engine of its own. This bridge lasts until the runtime hooks are split out +# of the kernel in 0.5.1. +MEMORY_TOOL = "agent-memory" +MEMORY_TOOL_INSTALL_HINT = "install it with `pip install agent-memory-cli`" +MEMORY_PULL_TIMEOUT_SECONDS = 60 + + def _pull_memory_report(*, hub_dir: Path, dry_run: bool) -> Dict[str, Any]: """Run the advisory startup pull and retain a compact result for hook context.""" marker = hub_dir / ".oacp-memory-repo" @@ -344,16 +356,38 @@ def _pull_memory_report(*, hub_dir: Path, dry_run: bool) -> Dict[str, Any]: "state": "dry-run", "messages": ["OACP memory pull skipped in dry-run mode."], } + tool = shutil.which(MEMORY_TOOL) + if tool is None: + return { + "state": "disabled", + "messages": [ + f"{MEMORY_TOOL} is not on PATH; startup pull skipped ({MEMORY_TOOL_INSTALL_HINT})." + ], + } try: - from memory_sync import MemorySyncError, pull_memory - - messages = pull_memory(hub_dir) - except (MemorySyncError, OSError, ValueError) as exc: - return {"state": "failed", "messages": [str(exc)]} + completed = subprocess.run( + [tool, "pull", "--home", str(hub_dir)], + capture_output=True, + text=True, + timeout=MEMORY_PULL_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return {"state": "failed", "messages": [f"{MEMORY_TOOL} pull: {exc}"]} + messages = [ + line.rstrip() + for line in (completed.stdout + completed.stderr).splitlines() + if line.strip() + ] + if completed.returncode != 0: + return { + "state": "failed", + "messages": messages or [f"{MEMORY_TOOL} pull exited {completed.returncode}"], + } state = "warning" if any(line.startswith("WARNING:") for line in messages) else "ok" - return {"state": state, "messages": messages} + return {"state": state, "messages": messages or [f"{MEMORY_TOOL} pull: ok"]} def _bounded_hook_context(text: str) -> str: @@ -369,7 +403,7 @@ def _bounded_hook_context(text: str) -> str: def build_session_start_hook_output( report: Dict[str, Any], *, - memory_sync: Optional[Dict[str, Any]] = None, + memory_pull: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Render concise developer context for the Codex ``SessionStart`` hook.""" protocol_paths = report["protocol"] @@ -383,7 +417,7 @@ def build_session_start_hook_output( memory_root = ( str(Path(readable_memory_paths[0]).parent) if readable_memory_paths else "(none)" ) - sync_state = str((memory_sync or {}).get("state", "not-requested")) + sync_state = str((memory_pull or {}).get("state", "not-requested")) verified_items = [ item for item in [*protocol_paths.values(), *memory_paths.values()] @@ -448,7 +482,7 @@ def _hook_input_error_output(message: str) -> Dict[str, Any]: "hookEventName": "SessionStart", "additionalContext": ( f"OACP startup verification did not run: {message}. " - "Before substantial work, run `oacp session-init --pull-memory` " + "Before substantial work, run `oacp session-init` " "manually and report the resulting SESSION_INIT_ACK." ), }, @@ -464,7 +498,7 @@ def _hook_runtime_error_output(message: str) -> Dict[str, Any]: "additionalContext": _bounded_hook_context( f"OACP startup verification failed after hook input was accepted: " f"{message}. Before substantial work, run " - "`oacp session-init --pull-memory` manually and report the " + "`oacp session-init` manually and report the " "resulting SESSION_INIT_ACK." ), }, @@ -541,7 +575,7 @@ def main() -> int: "status.yaml records model: unknown" ) - memory_sync = ( + memory_pull = ( _pull_memory_report(hub_dir=hub_dir, dry_run=args.dry_run) if args.pull_memory else None @@ -566,12 +600,12 @@ def main() -> int: return 0 if args.hook: - print(json.dumps(build_session_start_hook_output(report, memory_sync=memory_sync))) + print(json.dumps(build_session_start_hook_output(report, memory_pull=memory_pull))) return 0 if args.json_output: - if memory_sync is not None: - report["memory_sync"] = memory_sync + if memory_pull is not None: + report["memory_sync"] = memory_pull print(json.dumps(report, indent=2, sort_keys=True)) return 0 @@ -586,9 +620,9 @@ def main() -> int: item = report["memory"][name] print(f"- {name}: {item['state']}") print(f"status.yaml: {report['status_yaml'].get('state', 'unknown')}") - if memory_sync is not None: - print(f"memory pull: {memory_sync['state']}") - for message in memory_sync["messages"]: + if memory_pull is not None: + print(f"memory pull: {memory_pull['state']}") + for message in memory_pull["messages"]: print(f"- {message}") for warning in report["warnings"]: print(f"WARN: {warning}") diff --git a/scripts/init_org_memory.py b/scripts/init_org_memory.py deleted file mode 100644 index bdc1d3e..0000000 --- a/scripts/init_org_memory.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Initialize the org-level memory directory at $OACP_HOME/org-memory/. - -Creates the directory structure with scaffold files: - org-memory/ - recent.md - decisions.md - rules.md - events/ - debriefs/ - -Usage: - init_org_memory.py [--oacp-dir ] -""" - -from __future__ import annotations - -import argparse -import sys -from importlib import resources -from pathlib import Path -from typing import Optional, Sequence - -# Template files to copy into org-memory/ -_TEMPLATE_FILES = ( - "recent.md", - "decisions.md", - "rules.md", -) - - -def _find_template_dir() -> Optional[Path]: - """Locate the org-memory template directory.""" - # Development: templates/ in repo root - repo_root = Path(__file__).resolve().parent.parent - repo_templates = repo_root / "templates" / "org-memory" - if repo_templates.is_dir(): - return repo_templates - - # Installed: bundled in package - try: - ref = resources.files("oacp").joinpath("_templates", "org-memory") - # resources.files returns a Traversable; check if it's a real path - if hasattr(ref, "_path"): - p = Path(str(ref._path)) - if p.is_dir(): - return p - except Exception: - pass - - return None - - -def initialize_org_memory(oacp_root: Path) -> dict: - """Create org-memory/ directory structure under oacp_root. - - Returns a report dict with created/skipped files. - """ - org_memory_dir = oacp_root / "org-memory" - events_dir = org_memory_dir / "events" - debriefs_dir = org_memory_dir / "debriefs" - - # Create directories - org_memory_dir.mkdir(parents=True, exist_ok=True) - events_dir.mkdir(parents=True, exist_ok=True) - debriefs_dir.mkdir(parents=True, exist_ok=True) - - template_dir = _find_template_dir() - - created = [] - skipped = [] - - # .gitkeep so the empty debriefs/ tree survives git-based memory sync - gitkeep = debriefs_dir / ".gitkeep" - if gitkeep.exists(): - skipped.append("debriefs/.gitkeep") - else: - gitkeep.write_text("", encoding="utf-8") - created.append("debriefs/.gitkeep") - - for filename in _TEMPLATE_FILES: - target = org_memory_dir / filename - if target.exists(): - skipped.append(filename) - continue - - # Try to copy from template - if template_dir and (template_dir / filename).is_file(): - content = (template_dir / filename).read_text(encoding="utf-8") - else: - # Minimal fallback - title = filename.replace(".md", "").replace("_", " ").title() - content = f"# {title}\n" - - target.write_text(content, encoding="utf-8") - created.append(filename) - - return { - "org_memory_dir": org_memory_dir, - "created": created, - "skipped": skipped, - } - - -def parse_args(argv: Sequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="oacp org-memory", - description="Org-level memory commands.", - ) - sub = parser.add_subparsers(dest="subcommand") - init_parser = sub.add_parser("init", help="Initialize org-level memory directory") - init_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - # Default to "init" when no subcommand given - argv_list = list(argv) - if not argv_list or argv_list[0].startswith("-"): - argv_list = ["init"] + argv_list - return parser.parse_args(argv_list) - - -def main(argv: Optional[Sequence[str]] = None) -> int: - args = parse_args(sys.argv[1:] if argv is None else argv) - from _oacp_env import resolve_oacp_home - - oacp_root = resolve_oacp_home(explicit=args.oacp_dir) - - result = initialize_org_memory(oacp_root) - - org_dir = result["org_memory_dir"] - if result["created"]: - print(f"Initialized org-level memory: {org_dir}") - for f in result["created"]: - print(f" + {f}") - else: - print(f"Org-level memory already exists: {org_dir}") - - if result["skipped"]: - for f in result["skipped"]: - print(f" (exists) {f}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/memory_archive_common.py b/scripts/memory_archive_common.py deleted file mode 100644 index 2b8d6f6..0000000 --- a/scripts/memory_archive_common.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Shared helpers for active/archive memory file operations.""" - -from __future__ import annotations - -import datetime as dt -import re -from pathlib import Path -from typing import Tuple - - -ACTIVE_MEMORY_FILES = ( - "project_facts.md", - "decision_log.md", - "open_threads.md", - "known_debt.md", -) - -_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})$" -) - - -def validate_project_name(project_name: str) -> None: - if project_name.startswith(".") or "/" in project_name or "\\" in project_name: - raise ValueError("project name must not contain path separators or start with '.'") - - -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 ValueError( - "memory file name must be a simple basename containing only " - "[A-Za-z0-9._-]" - ) - - -def project_memory_paths(oacp_root: Path, project_name: str) -> Tuple[Path, Path, Path]: - validate_project_name(project_name) - project_dir = oacp_root / "projects" / project_name - if not project_dir.is_dir(): - raise ValueError(f"project '{project_name}' not found at {project_dir}") - memory_dir = project_dir / "memory" - archive_dir = memory_dir / "archive" - return project_dir, memory_dir, archive_dir - - -def build_archive_name( - memory_file: str, now: dt.datetime | None = None -) -> str: - validate_memory_basename(memory_file) - current = now or dt.datetime.now(dt.timezone.utc) - timestamp = current.astimezone(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") - return f"{timestamp}_{memory_file}" - - -def original_name_from_archive(archived_file: str) -> str: - if "/" in archived_file or "\\" in archived_file: - raise ValueError("archived file name must be a simple basename") - match = _ARCHIVED_BASENAME_RE.fullmatch(archived_file) - if match is None: - raise ValueError( - "archived file name must match _" - ) - return match.group("basename") diff --git a/scripts/memory_cli.py b/scripts/memory_cli.py deleted file mode 100644 index 190847b..0000000 --- a/scripts/memory_cli.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Namespace CLI for project memory archive/restore and sync operations.""" - -from __future__ import annotations - -import argparse -import json -import sys -from typing import Optional, Sequence - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="oacp memory", - description="Archive, restore, or sync OACP memory files.", - ) - sub = parser.add_subparsers(dest="command") - - init_parser = sub.add_parser("init", help="Initialize git sync at $OACP_HOME") - init_parser.add_argument( - "--remote", - default=None, - help="Optional git remote URL for cross-machine sync", - ) - init_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - - clone_parser = sub.add_parser("clone", help="Clone a memory repo into $OACP_HOME") - clone_parser.add_argument("url", help="Git remote URL to clone") - clone_parser.add_argument( - "--force", - action="store_true", - help="Move a non-empty OACP_HOME aside before cloning", - ) - clone_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - - pull_parser = sub.add_parser("pull", help="Fetch and fast-forward memory sync") - pull_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - - push_parser = sub.add_parser("push", help="Commit allowlisted memory and push") - push_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - - disable_parser = sub.add_parser("disable", help="Disable memory sync hooks locally") - disable_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - - archive_parser = sub.add_parser( - "archive", help="Move a non-standard memory file into memory/archive/" - ) - archive_parser.add_argument("project_name", help="Project workspace name") - archive_parser.add_argument("memory_file", help="Active memory file basename to archive") - archive_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - archive_parser.add_argument( - "--dry-run", action="store_true", help="Report actions without renaming" - ) - archive_parser.add_argument( - "--json", dest="json_output", action="store_true", help="Emit JSON output" - ) - - restore_parser = sub.add_parser( - "restore", help="Restore an archived memory file into the active working set" - ) - restore_parser.add_argument("project_name", help="Project workspace name") - restore_parser.add_argument("archived_file", help="Archived memory file basename to restore") - restore_parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - restore_parser.add_argument( - "--dry-run", action="store_true", help="Report actions without renaming" - ) - restore_parser.add_argument( - "--json", dest="json_output", action="store_true", help="Emit JSON output" - ) - - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> int: - from _oacp_env import resolve_oacp_home - from memory_sync import ( - MemorySyncError, - clone_memory_repo, - disable_memory_repo, - init_memory_repo, - pull_memory, - push_memory, - ) - from promote_to_archive import archive_memory_file - from restore_from_archive import restore_memory_file - - parser = _build_parser() - args = parser.parse_args(sys.argv[1:] if argv is None else argv) - - if args.command is None: - parser.print_help() - return 2 - - json_output = bool(getattr(args, "json_output", False)) - oacp_root = resolve_oacp_home(explicit=args.oacp_dir) - - try: - if args.command == "init": - result = {"action": "init"} - lines = init_memory_repo(oacp_root, remote=args.remote) - human = "\n".join(lines) - elif args.command == "clone": - result = {"action": "clone", "url": args.url, "oacp_root": str(oacp_root)} - lines = clone_memory_repo(oacp_root, args.url, force=args.force) - human = "\n".join(lines) - elif args.command == "pull": - result = {"action": "pull"} - lines = pull_memory(oacp_root) - human = "\n".join(lines) - elif args.command == "push": - result = {"action": "push"} - lines, code = push_memory(oacp_root) - human = "\n".join(lines) - if json_output: - result["exit_code"] = code - if not json_output and human: - print(human) - elif json_output: - print(json.dumps(result, indent=2, sort_keys=True)) - return code - elif args.command == "disable": - result = {"action": "disable"} - human = "\n".join(disable_memory_repo(oacp_root)) - elif args.command == "archive": - result = archive_memory_file( - args.project_name, - args.memory_file, - oacp_root=oacp_root, - dry_run=args.dry_run, - ) - human = ( - f"{'Would archive' if args.dry_run else 'Archived'} " - f"memory/{result['memory_file']} -> " - f"memory/archive/{result['archived_file']}" - ) - elif args.command == "restore": - result = restore_memory_file( - args.project_name, - args.archived_file, - oacp_root=oacp_root, - dry_run=args.dry_run, - ) - human = ( - f"{'Would restore' if args.dry_run else 'Restored'} " - f"memory/archive/{result['archived_file']} -> " - f"memory/{result['restored_file']}" - ) - else: - parser.print_help() - return 2 - except (MemorySyncError, ValueError) as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - if json_output: - print(json.dumps(result, indent=2, sort_keys=True)) - elif human: - print(human) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/memory_sync.py b/scripts/memory_sync.py deleted file mode 100644 index 03f9128..0000000 --- a/scripts/memory_sync.py +++ /dev/null @@ -1,584 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Plain-git sync helpers for OACP durable memory.""" - -from __future__ import annotations - -import datetime as dt -import os -import shutil -import socket -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, Iterable, List, Optional, Protocol, Sequence, Tuple - - -class Runner(Protocol): - def __call__( - self, - command: Sequence[str], - *, - timeout: Optional[int] = None, - ) -> Tuple[int, str]: ... - - -MARKER_FILE = ".oacp-memory-repo" -GIT_NETWORK_TIMEOUT_SECONDS = 30 -CANONICAL_MEMORY_GITIGNORE = """\ -* -!*/ -!.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/ -""" - -STALE_MEMORY_DAYS = 7 - - -class MemorySyncError(Exception): - """Raised when a memory sync command cannot safely proceed.""" - - -@dataclass -class GitState: - has_remote: bool - has_upstream: bool - ahead: int = 0 - behind: int = 0 - diverged: bool = False - dirty: bool = False - fetch_failed: bool = False - fetch_output: str = "" - upstream: str = "" - - -def run_command( - command: Sequence[str], - *, - cwd: Path, - timeout: Optional[int] = None, -) -> Tuple[int, str]: - """Run a command in *cwd* and return (exit_code, combined_output).""" - try: - completed = subprocess.run( - list(command), - cwd=str(cwd), - capture_output=True, - text=True, - check=False, - timeout=timeout, - ) - except FileNotFoundError: - return 127, f"Command not found: {command[0]}" - except subprocess.TimeoutExpired: - return 124, f"Command timed out after {timeout}s: {' '.join(command)}" - output = "\n".join(part for part in (completed.stdout, completed.stderr) if part) - return completed.returncode, output.strip() - - -def _git( - cwd: Path, - args: Sequence[str], - runner: Optional[Runner] = None, - *, - timeout: Optional[int] = None, -) -> Tuple[int, str]: - command = ["git", *args] - if runner is None: - return run_command(command, cwd=cwd, timeout=timeout) - if timeout is None: - return runner(command) - return runner(command, timeout=timeout) - - -def marker_path(oacp_root: Path) -> Path: - return oacp_root / MARKER_FILE - - -def is_configured(oacp_root: Path) -> bool: - return marker_path(oacp_root).is_file() - - -def is_git_repo(oacp_root: Path, runner: Optional[Runner] = None) -> bool: - rc, _ = _git(oacp_root, ["rev-parse", "--is-inside-work-tree"], runner) - return rc == 0 - - -def ensure_memory_repo(oacp_root: Path, runner: Optional[Runner] = None) -> None: - if not is_configured(oacp_root): - raise MemorySyncError("OACP memory sync is not configured.") - if not is_git_repo(oacp_root, runner): - raise MemorySyncError( - f"{MARKER_FILE} is present, but {oacp_root} is not a git repository." - ) - - -def write_canonical_gitignore(oacp_root: Path) -> None: - (oacp_root / ".gitignore").write_text( - CANONICAL_MEMORY_GITIGNORE, - encoding="utf-8", - ) - - -def write_marker(oacp_root: Path) -> None: - marker_path(oacp_root).write_text( - "OACP memory sync repository. Remove this file to disable hooks locally.\n", - encoding="utf-8", - ) - - -def allowed_memory_dirs(oacp_root: Path) -> List[Path]: - paths: List[Path] = [] - org_memory = oacp_root / "org-memory" - if org_memory.exists(): - paths.append(org_memory) - projects = oacp_root / "projects" - if projects.is_dir(): - for project_dir in sorted(projects.iterdir()): - memory_dir = project_dir / "memory" - if memory_dir.exists(): - paths.append(memory_dir) - return paths - - -def add_allowlist_paths(oacp_root: Path, runner: Optional[Runner] = None) -> None: - paths = [oacp_root / ".gitignore", marker_path(oacp_root), *allowed_memory_dirs(oacp_root)] - existing = [str(path.relative_to(oacp_root)) for path in paths if path.exists()] - if existing: - rc, output = _git(oacp_root, ["add", "--", *existing], runner) - if rc != 0: - raise MemorySyncError(f"git add failed: {output}") - - -def has_remote(oacp_root: Path, runner: Optional[Runner] = None) -> bool: - rc, output = _git(oacp_root, ["remote"], runner) - return rc == 0 and bool(output.strip()) - - -def default_remote(oacp_root: Path, runner: Optional[Runner] = None) -> str: - rc, output = _git(oacp_root, ["remote"], runner) - if rc != 0: - return "" - remotes = [line.strip() for line in output.splitlines() if line.strip()] - return remotes[0] if remotes else "" - - -def remote_exists( - oacp_root: Path, - remote_name: str, - runner: Optional[Runner] = None, -) -> bool: - rc, _ = _git(oacp_root, ["remote", "get-url", remote_name], runner) - return rc == 0 - - -def configured_upstream(oacp_root: Path, runner: Optional[Runner] = None) -> str: - rc, output = _git( - oacp_root, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], - runner, - ) - return output.strip() if rc == 0 else "" - - -def status_porcelain(oacp_root: Path, runner: Optional[Runner] = None) -> str: - rc, output = _git(oacp_root, ["status", "--porcelain"], runner) - if rc != 0: - raise MemorySyncError(f"git status failed: {output}") - return output.strip() - - -def current_branch(oacp_root: Path, runner: Optional[Runner] = None) -> str: - rc, output = _git(oacp_root, ["branch", "--show-current"], runner) - if rc == 0 and output.strip(): - return output.strip() - return "HEAD" - - -def fetch_remote(oacp_root: Path, runner: Optional[Runner] = None) -> Tuple[bool, str]: - if not has_remote(oacp_root, runner): - return True, "" - rc, output = _git( - oacp_root, - ["fetch", "--quiet"], - runner, - timeout=GIT_NETWORK_TIMEOUT_SECONDS, - ) - return rc == 0, output - - -def compute_git_state( - oacp_root: Path, - *, - runner: Optional[Runner] = None, - fetch: bool = True, -) -> GitState: - dirty = bool(status_porcelain(oacp_root, runner)) - remote = has_remote(oacp_root, runner) - fetch_failed = False - fetch_output = "" - if fetch and remote: - ok, fetch_output = fetch_remote(oacp_root, runner) - fetch_failed = not ok - - upstream = configured_upstream(oacp_root, runner) - state = GitState( - has_remote=remote, - has_upstream=bool(upstream), - dirty=dirty, - fetch_failed=fetch_failed, - fetch_output=fetch_output, - upstream=upstream, - ) - if not upstream: - return state - - rc, output = _git( - oacp_root, - ["rev-list", "--left-right", "--count", f"HEAD...{upstream}"], - runner, - ) - if rc != 0: - state.fetch_failed = True - state.fetch_output = output - return state - parts = output.split() - if len(parts) >= 2: - state.ahead = int(parts[0]) - state.behind = int(parts[1]) - state.diverged = state.ahead > 0 and state.behind > 0 - return state - - -def state_warnings(state: GitState, *, include_dirty: bool = True) -> List[str]: - warnings: List[str] = [] - if state.fetch_failed: - detail = f": {state.fetch_output}" if state.fetch_output else "" - warnings.append(f"WARNING: remote fetch failed{detail}") - if include_dirty and state.dirty: - warnings.append( - "WARNING: uncommitted memory changes are present; memory is not clean." - ) - if state.diverged: - warnings.append( - "WARNING: memory repo has diverged from its upstream; resolve manually." - ) - elif state.behind: - warnings.append( - f"WARNING: memory repo is behind upstream by {state.behind} commit(s)." - ) - elif state.ahead: - warnings.append( - f"WARNING: memory repo has {state.ahead} unpushed commit(s); wrap-up will push." - ) - return warnings - - -def staged_files(oacp_root: Path, runner: Optional[Runner] = None) -> List[str]: - rc, output = _git(oacp_root, ["diff", "--cached", "--name-only"], runner) - if rc != 0: - raise MemorySyncError(f"git diff --cached failed: {output}") - return [line for line in output.splitlines() if line.strip()] - - -def has_commits(oacp_root: Path, runner: Optional[Runner] = None) -> bool: - rc, _ = _git(oacp_root, ["rev-parse", "--verify", "HEAD"], runner) - return rc == 0 - - -def make_commit_message(file_count: int, *, env: Optional[Dict[str, str]] = None) -> str: - source = env if env is not None else os.environ - agent = ( - source.get("OACP_AGENT") - or source.get("AGENT_NAME") - or source.get("USER") - or "unknown" - ) - host = socket.gethostname().split(".")[0] or "host" - today = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") - return f"memory: {agent}@{host} {today} ({file_count} files)" - - -def push_remote(oacp_root: Path, runner: Optional[Runner] = None) -> Tuple[int, str]: - if not has_remote(oacp_root, runner): - return 0, "No memory remote configured; commit remains local." - upstream = configured_upstream(oacp_root, runner) - if upstream: - return _git( - oacp_root, - ["push"], - runner, - timeout=GIT_NETWORK_TIMEOUT_SECONDS, - ) - remote = default_remote(oacp_root, runner) - branch = current_branch(oacp_root, runner) - return _git( - oacp_root, - ["push", "-u", remote, branch], - runner, - timeout=GIT_NETWORK_TIMEOUT_SECONDS, - ) - - -def pull_memory(oacp_root: Path, runner: Optional[Runner] = None) -> List[str]: - """Advisory pull used by session-start hooks. Returns human output lines.""" - if not is_configured(oacp_root): - return [] - ensure_memory_repo(oacp_root, runner) - state = compute_git_state(oacp_root, runner=runner, fetch=True) - lines = state_warnings(state) - if state.dirty or state.diverged or state.ahead or state.fetch_failed: - return lines - if not state.has_upstream: - if state.has_remote: - return ["OACP memory pull: no upstream branch configured; skipping."] - return ["OACP memory pull: local-only memory repo; no remote to pull."] - if state.behind: - behind = state.behind - rc, output = _git( - oacp_root, - ["pull", "--ff-only"], - runner, - timeout=GIT_NETWORK_TIMEOUT_SECONDS, - ) - if rc != 0: - return [*lines, f"WARNING: memory pull --ff-only failed: {output}"] - return [f"OACP memory pull: synced {behind} commit(s)."] - return ["OACP memory pull: already synced."] - - -def push_memory(oacp_root: Path, runner: Optional[Runner] = None) -> Tuple[List[str], int]: - """Commit allowlisted memory changes and push when a remote exists.""" - if not is_configured(oacp_root): - return [], 0 - ensure_memory_repo(oacp_root, runner) - state = compute_git_state(oacp_root, runner=runner, fetch=True) - lines = state_warnings(state, include_dirty=False) - if state.diverged: - return [ - *lines, - "ERROR: memory repo is diverged; resolve manually before pushing.", - ], 1 - if state.behind: - return [ - *lines, - "ERROR: memory repo is behind upstream; pull before pushing memory.", - ], 1 - add_allowlist_paths(oacp_root, runner) - files = staged_files(oacp_root, runner) - if files: - message = make_commit_message(len(files)) - rc, output = _git(oacp_root, ["commit", "-m", message], runner) - if rc != 0: - return [*lines, f"ERROR: memory commit failed: {output}"], 1 - lines.append(f"OACP memory push: committed {len(files)} file(s).") - else: - lines.append("OACP memory push: no memory changes to commit.") - - rc, output = push_remote(oacp_root, runner) - if rc != 0: - lines.append( - "WARNING: memory push failed; commit remains local. " - f"Resolve before assuming memory is synced. {output}".rstrip() - ) - return lines, 1 - if output: - lines.append(output) - elif has_remote(oacp_root, runner): - lines.append("OACP memory push: pushed to remote.") - return lines, 0 - - -def init_memory_repo( - oacp_root: Path, - *, - remote: Optional[str] = None, - runner: Optional[Runner] = None, -) -> List[str]: - oacp_root.mkdir(parents=True, exist_ok=True) - if not is_git_repo(oacp_root, runner): - rc, output = _git(oacp_root, ["init"], runner) - if rc != 0: - raise MemorySyncError(f"git init failed: {output}") - - write_canonical_gitignore(oacp_root) - write_marker(oacp_root) - if remote: - if remote_exists(oacp_root, "origin", runner): - rc, output = _git(oacp_root, ["remote", "set-url", "origin", remote], runner) - if rc != 0: - raise MemorySyncError(f"git remote set-url failed: {output}") - else: - rc, output = _git(oacp_root, ["remote", "add", "origin", remote], runner) - if rc != 0: - raise MemorySyncError(f"git remote add failed: {output}") - - add_allowlist_paths(oacp_root, runner) - files = staged_files(oacp_root, runner) - lines: List[str] = [] - if files: - rc, output = _git( - oacp_root, - ["commit", "-m", make_commit_message(len(files))], - runner, - ) - if rc != 0: - raise MemorySyncError(f"initial memory commit failed: {output}") - lines.append(f"Initialized OACP memory repo with {len(files)} file(s).") - else: - lines.append("OACP memory repo already initialized; no changes to commit.") - - if remote: - rc, output = push_remote(oacp_root, runner) - if rc != 0: - lines.append( - "WARNING: initial memory push failed; commit remains local. " - f"{output}".rstrip() - ) - else: - lines.append("OACP memory remote configured.") - return lines - - -def _is_non_empty(path: Path) -> bool: - return path.exists() and any(path.iterdir()) - - -def clone_memory_repo( - oacp_root: Path, - url: str, - *, - force: bool = False, - runner: Optional[Runner] = None, -) -> List[str]: - if _is_non_empty(oacp_root) and not force: - raise MemorySyncError( - f"Refusing to clone into non-empty OACP_HOME: {oacp_root}. " - "Pass --force to move it aside first." - ) - - if not oacp_root.exists() or not _is_non_empty(oacp_root): - oacp_root.parent.mkdir(parents=True, exist_ok=True) - rc, output = _git( - oacp_root.parent, - ["clone", url, str(oacp_root)], - runner, - timeout=GIT_NETWORK_TIMEOUT_SECONDS, - ) - if rc != 0: - raise MemorySyncError(f"git clone failed: {output}") - return [f"Cloned OACP memory repo into {oacp_root}."] - - backup = oacp_root.with_name( - f"{oacp_root.name}.backup-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%d%H%M%S')}" - ) - shutil.move(str(oacp_root), str(backup)) - try: - rc, output = _git( - oacp_root.parent, - ["clone", url, str(oacp_root)], - runner, - timeout=GIT_NETWORK_TIMEOUT_SECONDS, - ) - except Exception: - if not oacp_root.exists(): - shutil.move(str(backup), str(oacp_root)) - raise - if rc != 0: - if oacp_root.exists(): - shutil.rmtree(oacp_root) - shutil.move(str(backup), str(oacp_root)) - raise MemorySyncError(f"git clone failed: {output}") - return [ - f"Moved existing OACP_HOME aside to {backup}.", - f"Cloned OACP memory repo into {oacp_root}.", - ] - - -def disable_memory_repo(oacp_root: Path) -> List[str]: - marker = marker_path(oacp_root) - if marker.exists(): - marker.unlink() - return [f"Removed {marker}; memory hooks are disabled locally."] - return ["OACP memory sync already disabled."] - - -def normalize_gitignore(text: str) -> str: - return text.replace("\r\n", "\n") - - -def is_allowed_memory_path(path: str) -> bool: - if path in {".gitignore", MARKER_FILE}: - return True - if path.startswith("org-memory/"): - return True - parts = path.split("/") - if len(parts) >= 4 and parts[0] == "projects" and parts[2] == "memory": - if parts[3] == ".cache": - return False - return True - return False - - -def tracked_files(oacp_root: Path, runner: Optional[Runner] = None) -> List[str]: - rc, output = _git(oacp_root, ["ls-files"], runner) - if rc != 0: - raise MemorySyncError(f"git ls-files failed: {output}") - return [line.strip() for line in output.splitlines() if line.strip()] - - -def untracked_files(oacp_root: Path, runner: Optional[Runner] = None) -> List[str]: - rc, output = _git( - oacp_root, - ["ls-files", "--others", "--exclude-standard"], - runner, - ) - if rc != 0: - raise MemorySyncError(f"git ls-files --others failed: {output}") - return [line.strip() for line in output.splitlines() if line.strip()] - - -def overlay_gitignores(oacp_root: Path) -> Iterable[Path]: - projects = oacp_root / "projects" - if not projects.is_dir(): - return [] - return sorted(projects.glob("*/memory/.gitignore")) - - -def escaping_overlay_patterns(path: Path) -> List[str]: - 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 last_commit_age_days( - oacp_root: Path, - *, - now: Optional[dt.datetime] = None, - runner: Optional[Runner] = None, -) -> Optional[int]: - if not has_commits(oacp_root, runner): - return None - rc, output = _git(oacp_root, ["log", "-1", "--format=%ct"], runner) - if rc != 0: - return None - try: - ts = int(output.strip()) - except ValueError: - return None - current = now or dt.datetime.now(dt.timezone.utc) - commit_time = dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc) - return max(0, int((current - commit_time).total_seconds() // 86400)) diff --git a/scripts/oacp_doctor.py b/scripts/oacp_doctor.py index 717bfa1..250cf70 100644 --- a/scripts/oacp_doctor.py +++ b/scripts/oacp_doctor.py @@ -24,7 +24,6 @@ import argparse import datetime as dt import json -import os import re import shutil import subprocess @@ -35,7 +34,6 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from _oacp_constants import ( - AGENT_RE, ALL_RUNTIMES, CANONICAL_CAPABILITIES, REPO_SLUG_RE, @@ -44,29 +42,15 @@ utc_now_iso, ) from agent_profile import discover_project_memberships -from memory_sync import ( - CANONICAL_MEMORY_GITIGNORE, - MARKER_FILE, - Runner as MemorySyncRunner, - STALE_MEMORY_DAYS, - MemorySyncError, - compute_git_state, - escaping_overlay_patterns, - has_commits, - is_allowed_memory_path, - is_configured, - is_git_repo, - last_commit_age_days, - normalize_gitignore, - overlay_gitignores, - run_command as run_git_command, - tracked_files, - untracked_files, -) DoctorRunner = Callable[[Sequence[str]], Tuple[int, str]] WhichFn = Callable[[str], Optional[str]] +# The memory tool. Resolved on PATH and probed by subprocess only; the kernel +# never imports it, and its own doctor covers the memory home. +MEMORY_TOOL = "agent-memory" +MEMORY_TOOL_DISTRIBUTION = "agent-memory-cli" + VALID_RUNTIMES = set(ALL_RUNTIMES) VALID_STATUSES = {"available", "busy", "offline"} VALID_AUTONOMY_MODES = {"always_pause", "auto_review"} @@ -222,6 +206,23 @@ def check_environment( message=f"{tool} — {version}", )) + # The memory tool (one row; `agent-memory doctor` checks the home itself) + path = which_fn(MEMORY_TOOL) + if path is None: + cat.results.append(DoctorResult( + name=MEMORY_TOOL, + severity=Severity.skip, + message=f"{MEMORY_TOOL} — not installed", + fix_hint=f"Install: pip install {MEMORY_TOOL_DISTRIBUTION}", + )) + else: + version = _get_version(MEMORY_TOOL, runner) or "installed" + cat.results.append(DoctorResult( + name=MEMORY_TOOL, + severity=Severity.ok, + message=f"{MEMORY_TOOL} — {version} (run {MEMORY_TOOL} doctor)", + )) + # pyyaml yaml_mod = _try_yaml_import() if yaml_mod is None: @@ -1283,9 +1284,6 @@ def check_trust( return cat -# ── Category 7: Memory Sync ────────────────────────────────────────────── - - def _summarize_paths(paths: List[str], *, limit: int = 3) -> str: if not paths: return "" @@ -1295,619 +1293,10 @@ def _summarize_paths(paths: List[str], *, limit: int = 3) -> str: return shown -def check_memory_sync( - oacp_dir: Path, - *, - runner: DoctorRunner = run_command, - now_fn: Optional[Callable[[], dt.datetime]] = None, -) -> DoctorCategory: - """Check OACP_HOME memory sync configuration and git state.""" - cat = DoctorCategory(name="Memory Sync") - marker = oacp_dir / MARKER_FILE - - if runner is run_command: - def default_git_runner( - command: Sequence[str], - *, - timeout: Optional[int] = None, - ) -> Tuple[int, str]: - return run_git_command(command, cwd=oacp_dir, timeout=timeout) - - git_runner: MemorySyncRunner = default_git_runner - else: - def custom_git_runner( - command: Sequence[str], - *, - timeout: Optional[int] = None, - ) -> Tuple[int, str]: - del timeout - return runner(command) - - git_runner = custom_git_runner - - if not is_configured(oacp_dir): - cat.results.append( - DoctorResult( - name="memory-marker", - severity=Severity.skip, - message=f"{MARKER_FILE} — not configured; memory sync hooks are disabled", - fix_hint="Run: oacp memory init [--remote URL]", - ) - ) - return cat - - cat.results.append( - DoctorResult( - name="memory-marker", - severity=Severity.ok, - message=f"{MARKER_FILE} — present", - ) - ) - - if not is_git_repo(oacp_dir, git_runner): - cat.results.append( - DoctorResult( - name="memory-git", - severity=Severity.warn, - message=f"{marker} is present, but OACP_HOME is not a git repo", - fix_hint="Run `oacp memory init` or remove the marker to disable hooks", - ) - ) - return cat - - root_gitignore = oacp_dir / ".gitignore" - if not root_gitignore.is_file(): - cat.results.append( - DoctorResult( - name="root-gitignore", - severity=Severity.warn, - message=".gitignore — missing canonical memory allowlist", - fix_hint="Run: oacp memory init", - ) - ) - else: - content = normalize_gitignore(root_gitignore.read_text(encoding="utf-8")) - if content == CANONICAL_MEMORY_GITIGNORE: - cat.results.append( - DoctorResult( - name="root-gitignore", - severity=Severity.ok, - message=".gitignore — canonical memory allowlist", - ) - ) - else: - cat.results.append( - DoctorResult( - name="root-gitignore", - severity=Severity.warn, - message=".gitignore — drifted from canonical memory allowlist", - fix_hint="Run `oacp memory init` to rewrite the root allowlist", - ) - ) - - tracked: Optional[List[str]] = None - try: - tracked = tracked_files(oacp_dir, git_runner) - outside = [path for path in tracked if not is_allowed_memory_path(path)] - except MemorySyncError as exc: - cat.results.append( - DoctorResult( - name="tracked-allowlist", - severity=Severity.warn, - message=f"tracked allowlist check failed: {exc}", - ) - ) - else: - if outside: - cat.results.append( - DoctorResult( - name="tracked-allowlist", - severity=Severity.warn, - message=( - f"{len(outside)} tracked file(s) outside memory allowlist: " - f"{_summarize_paths(outside)}" - ), - fix_hint="Remove runtime state from the memory repo index", - ) - ) - else: - cat.results.append( - DoctorResult( - name="tracked-allowlist", - severity=Severity.ok, - message=f"tracked files — {len(tracked)} inside memory allowlist", - ) - ) - - try: - untracked = [ - path - for path in untracked_files(oacp_dir, git_runner) - if is_allowed_memory_path(path) - ] - except MemorySyncError as exc: - cat.results.append( - DoctorResult( - name="untracked-memory", - severity=Severity.warn, - message=f"untracked memory check failed: {exc}", - ) - ) - else: - if untracked: - cat.results.append( - DoctorResult( - name="untracked-memory", - severity=Severity.warn, - message=( - f"{len(untracked)} untracked memory-shaped file(s): " - f"{_summarize_paths(untracked)}" - ), - fix_hint="Run: oacp memory push", - ) - ) - else: - cat.results.append( - DoctorResult( - name="untracked-memory", - severity=Severity.ok, - message="untracked memory files — none", - ) - ) - - try: - state = compute_git_state(oacp_dir, runner=git_runner, fetch=True) - except MemorySyncError as exc: - cat.results.append( - DoctorResult( - name="working-tree", - severity=Severity.warn, - message=f"memory git state check failed: {exc}", - ) - ) - state = None - - if state is not None: - if state.dirty: - cat.results.append( - DoctorResult( - name="working-tree", - severity=Severity.warn, - message="working tree — DIRTY memory changes present", - fix_hint="Run `oacp memory push` or resolve changes manually", - ) - ) - else: - cat.results.append( - DoctorResult( - name="working-tree", - severity=Severity.ok, - message="working tree — clean", - ) - ) - - if not state.has_remote: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.ok, - message="sync state — local-only; no remote configured", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.skip, - message="remote — skipped; local-only memory repo", - ) - ) - elif state.fetch_failed: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.warn, - message=f"sync state — remote fetch failed: {state.fetch_output}", - fix_hint="Check network access and remote permissions", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.warn, - message="remote — not reachable", - fix_hint="Check network access and remote permissions", - ) - ) - elif not state.has_upstream: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.warn, - message="sync state — remote exists but no upstream branch is configured", - fix_hint="Run: git -C $OACP_HOME push -u ", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.ok, - message="remote — reachable", - ) - ) - elif state.diverged: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.warn, - message=( - "sync state — DIVERGED from upstream " - f"({state.ahead} ahead, {state.behind} behind)" - ), - fix_hint="Resolve manually; OACP never auto-merges memory", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.ok, - message="remote — reachable", - ) - ) - elif state.behind: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.warn, - message=f"sync state — BEHIND upstream by {state.behind} commit(s)", - fix_hint="Run: oacp memory pull", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.ok, - message="remote — reachable", - ) - ) - elif state.ahead: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.warn, - message=f"sync state — ahead by {state.ahead} unpushed commit(s)", - fix_hint="Run: oacp memory push", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.ok, - message="remote — reachable", - ) - ) - else: - cat.results.append( - DoctorResult( - name="sync-state", - severity=Severity.ok, - message="sync state — synced with upstream", - ) - ) - cat.results.append( - DoctorResult( - name="remote", - severity=Severity.ok, - message="remote — reachable", - ) - ) - - if not has_commits(oacp_dir, git_runner): - cat.results.append( - DoctorResult( - name="last-commit", - severity=Severity.warn, - message="last commit — none", - fix_hint="Run: oacp memory push", - ) - ) - else: - now = now_fn() if now_fn is not None else dt.datetime.now(dt.timezone.utc) - age_days = last_commit_age_days(oacp_dir, now=now, runner=git_runner) - if age_days is None: - cat.results.append( - DoctorResult( - name="last-commit", - severity=Severity.warn, - message="last commit — timestamp unavailable", - ) - ) - elif age_days > STALE_MEMORY_DAYS: - cat.results.append( - DoctorResult( - name="last-commit", - severity=Severity.warn, - message=f"last commit — stale ({age_days} day(s) old)", - fix_hint="Run: oacp memory push", - ) - ) - else: - cat.results.append( - DoctorResult( - name="last-commit", - severity=Severity.ok, - message=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("projects/") and "/agents/" in path) - ] - if agents_tracked: - cat.results.append( - DoctorResult( - name="agents-tracked", - severity=Severity.warn, - message=( - f"{len(agents_tracked)} agents/ file(s) tracked: " - f"{_summarize_paths(agents_tracked)}" - ), - fix_hint="Remove per-instance agent state from the memory repo", - ) - ) - else: - cat.results.append( - DoctorResult( - name="agents-tracked", - severity=Severity.ok, - message="agents/ tracked files — none", - ) - ) - - bad_overlays: List[str] = [] - overlays = list(overlay_gitignores(oacp_dir)) - for overlay in overlays: - for pattern in escaping_overlay_patterns(overlay): - bad_overlays.append(f"{overlay.relative_to(oacp_dir)}: {pattern}") - if bad_overlays: - cat.results.append( - DoctorResult( - name="memory-overlays", - severity=Severity.warn, - message=( - "memory .gitignore overlays can escape memory/**: " - f"{_summarize_paths(bad_overlays)}" - ), - fix_hint="Remove overlay unignore patterns containing '..'", - ) - ) - else: - cat.results.append( - DoctorResult( - name="memory-overlays", - severity=Severity.ok, - message=f"memory .gitignore overlays — {len(overlays)} safe", - ) - ) - - return cat - - -# ── Orchestrator ────────────────────────────────────────────────────────── - - -# ── Category 8: Org-Memory Debrief Store ───────────────────────────────── - - -# The agent segment is the protocol's canonical agent grammar (AGENT_RE); -# the session segment is hyphen-free, so the split-on-last-hyphen parse is -# deterministic for every valid agent name. -_AGENT_FRAGMENT = AGENT_RE.pattern.lstrip("^").rstrip("$") -DEBRIEF_FILENAME_RE = re.compile( - rf"^(?P\d{{8}})-(?P{_AGENT_FRAGMENT})-(?P[a-z0-9]{{1,32}})\.md$" -) - - -def _valid_debrief_project_segment(name: str) -> bool: - # Mirrors the workspace project-name rule: any name that does not start - # with '.' and contains no path separators has a valid debrief path. - return not name.startswith(".") and "/" not in name and "\\" not in name - - -# ── Debrief store validation scope ─────────────────────────────────────── -# 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 debrief files — content and format verification belong to -# the writer contract (read-back at publication) and to git history, and -# the store is written by trusted local agents, so the doctor is a -# diagnostic for accidental drift, not a security boundary. One working -# rule: a failed traversal or classification produces an explicit non-ok -# row, never a clean result. - - -def check_org_memory(oacp_dir: Path) -> DoctorCategory: - """Check the org-memory debrief store setup: layout, staging, symlinks.""" - cat = DoctorCategory(name="Org Memory") - org_memory = oacp_dir / "org-memory" - if not org_memory.is_dir(): - cat.results.append(DoctorResult( - name="org-memory-dir", - severity=Severity.skip, - message="org-memory/ — not initialized", - fix_hint="Run: oacp org-memory init", - )) - return cat - - debriefs = org_memory / "debriefs" - if not debriefs.is_dir(): - cat.results.append(DoctorResult( - name="debriefs-dir", - severity=Severity.warn, - message="org-memory/debriefs/ — missing (pre-debrief-store layout)", - fix_hint="Run: oacp org-memory init", - )) - return cat - cat.results.append(DoctorResult( - name="debriefs-dir", - severity=Severity.ok, - message="org-memory/debriefs/ — present", - )) - - layout_bad: List[str] = [] - staging: List[str] = [] - irregular: List[str] = [] - walk_errors: List[str] = [] - total = 0 - - def _walk_error(exc: OSError) -> None: - # A directory the walk cannot enter hides an unknown number of - # records; the failure must surface as its own row. - location = getattr(exc, "filename", None) or str(debriefs) - try: - rel_loc = Path(location).relative_to(debriefs).as_posix() or "." - except ValueError: - rel_loc = str(location) - walk_errors.append(f"{rel_loc}: {exc.__class__.__name__}") - - entries: List[Path] = [] - # followlinks=False so a symlinked directory cannot pull foreign trees - # into the store; the link itself is still flagged below. - for dirpath, dirnames, filenames in os.walk( - debriefs, onerror=_walk_error, followlinks=False - ): - dpath = Path(dirpath) - kept: List[str] = [] - for dname in sorted(dirnames): - entry = dpath / dname - try: - is_link = entry.is_symlink() - except OSError as exc: - walk_errors.append( - f"{entry.relative_to(debriefs).as_posix()}: " - f"{exc.__class__.__name__}" - ) - continue - if is_link: - irregular.append( - entry.relative_to(debriefs).as_posix() + "/ (symlinked directory)" - ) - else: - kept.append(dname) - dirnames[:] = kept - entries.extend(dpath / f for f in filenames) - - for file_path in sorted(entries): - rel = file_path.relative_to(debriefs).as_posix() - if rel == ".gitkeep": - continue - # Writer staging artifacts (.stage..) are outside the - # canonical namespace; lingering ones mean interrupted publication. - if file_path.name.startswith(".stage."): - staging.append(rel) - continue - # The namespace holds regular files reached without following - # links; classification failures surface, never raise. - 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_debrief_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.results.append(DoctorResult( - name="debriefs-staging", - severity=Severity.warn, - message=( - f"{len(staging)} lingering writer staging artifact(s) " - f"(interrupted publication): {_summarize_paths(staging)}" - ), - fix_hint="The owning writer removes or adopts its stale staging files on retry", - )) - - if irregular: - cat.results.append(DoctorResult( - name="debriefs-irregular", - severity=Severity.error, - message=( - f"{len(irregular)} non-regular entr(ies) under debriefs/ " - f"(the store holds regular files, never symlinks): " - f"{_summarize_paths(irregular)}" - ), - )) - - if walk_errors: - cat.results.append(DoctorResult( - name="debriefs-unreadable", - severity=Severity.error, - message=( - f"{len(walk_errors)} entr(ies) under debriefs/ could not be " - f"inspected (setup check incomplete): " - f"{_summarize_paths(walk_errors)}" - ), - )) - - if total == 0: - if not walk_errors: - cat.results.append(DoctorResult( - name="debriefs-layout", - severity=Severity.ok, - message="debriefs/ — empty store, nothing to validate", - )) - return cat - - if layout_bad: - cat.results.append(DoctorResult( - name="debriefs-layout", - severity=Severity.error, - message=( - f"{len(layout_bad)} of {total} debrief file(s) outside the " - f"canonical ///--.md " - f"layout: {_summarize_paths(layout_bad)}" - ), - fix_hint="Move or rename to the canonical path; never rewrite contents", - )) - else: - cat.results.append(DoctorResult( - name="debriefs-layout", - severity=Severity.ok, - message=f"{total} debrief file(s) — canonical layout", - )) - - return cat - - def run_doctor( *, project: Optional[str] = None, oacp_dir: Path, - include_memory: bool = False, runner: DoctorRunner = run_command, yaml_loader: Optional[Any] = None, which_fn: WhichFn = shutil.which, @@ -1919,10 +1308,6 @@ def run_doctor( # Always run environment checks categories.append(check_environment(runner=runner, which_fn=which_fn)) - # Org-memory is opt-in: debrief-store checks run only when it exists - if (oacp_dir / "org-memory").is_dir(): - categories.append(check_org_memory(oacp_dir)) - if (oacp_dir / "projects").is_dir(): categories.append(check_agent_registry(oacp_dir, yaml_loader=yaml_loader)) @@ -1946,9 +1331,6 @@ def run_doctor( categories.append(check_agent_status(project_dir, yaml_loader=yaml_loader, now_fn=now_fn)) categories.append(check_trust(project_dir, yaml_loader=yaml_loader)) - if include_memory: - categories.append(check_memory_sync(oacp_dir, runner=runner, now_fn=now_fn)) - return categories @@ -2181,11 +1563,6 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: action="store_true", help="Auto-fix safe issues (missing inbox dirs, missing/stale status.yaml)", ) - parser.add_argument( - "--memory", - action="store_true", - help="Run advisory checks for OACP_HOME memory git sync", - ) parser.add_argument( "-o", "--output", @@ -2203,7 +1580,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int: categories = run_doctor( project=args.project, oacp_dir=oacp_dir, - include_memory=args.memory, ) fixed: List[str] = [] diff --git a/scripts/preflight.py b/scripts/preflight.py index 9c66d2d..df8f686 100644 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -17,6 +17,7 @@ from __future__ import annotations import argparse +import ast import re import shutil import subprocess @@ -278,6 +279,122 @@ def check_packaging_boundary(repo_root: Path) -> CheckResult: ) +# The memory engine lives outside the kernel (`agent-memory-cli`). No kernel +# module may import it, in either of its spellings: the retired in-tree +# `memory_*` modules or the tool's `agent_memory` package. The check walks the +# parsed module rather than matching lines, so every alias of a comma-list +# import, semicolon-joined statements, relative (`from . import memory_sync`) +# and qualified (`oacp._scripts.memory_sync`) forms, function-local imports, +# and a dynamic import by string literal, whether the literal is the module +# or the package it resolves against, are all caught, while a string or +# comment that merely names a module is not. +MEMORY_MODULE_RE = re.compile(r"^(?:memory_\w*|agent_memory)$") +# Parameters of the dynamic importers that name a module, as (keyword, +# position): `import_module(name, package=None)` and `__import__(name, +# globals, locals, fromlist, level)`. Only literal strings are checkable. +DYNAMIC_IMPORT_MODULE_PARAMS = { + "import_module": (("name", 0), ("package", 1)), + "__import__": (("name", 0), ("fromlist", 3)), +} +KERNEL_MODULE_DIRS = ("oacp", "scripts") + + +def _iter_kernel_modules(repo_root: Path) -> List[Path]: + modules: List[Path] = [] + for dirname in KERNEL_MODULE_DIRS: + root = repo_root / dirname + if not root.is_dir(): + continue + for path in sorted(root.rglob("*.py")): + rel = path.relative_to(repo_root) + if any(part in SKIP_DIRS or part.startswith(".") for part in rel.parts): + continue + modules.append(path) + return modules + + +def _names_memory_engine(dotted: str) -> bool: + return any(MEMORY_MODULE_RE.match(part) for part in dotted.split(".")) + + +def _call_argument(node: ast.Call, keyword: str, position: int) -> Optional[ast.AST]: + """The argument passed for one parameter, positionally or by keyword.""" + if position < len(node.args): + return node.args[position] + return next((kw.value for kw in node.keywords if kw.arg == keyword), None) + + +def _literal_strings(node: Optional[ast.AST]) -> List[str]: + """String constants in a literal, or in the elements of a literal list/tuple.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return [node.value] + if isinstance(node, (ast.List, ast.Tuple)): + return [text for elt in node.elts for text in _literal_strings(elt)] + return [] + + +def _imported_names(node: ast.AST) -> List[str]: + """Dotted names an import-shaped node resolves; [] for any other node.""" + if isinstance(node, ast.Import): + return [alias.name for alias in node.names] + if isinstance(node, ast.ImportFrom): + # `from pkg.memory_sync import x` names the module on the left; + # `from pkg import memory_sync` and `from . import memory_sync` name + # it as the imported item, so both sides are checked. + return [node.module or "", *(alias.name for alias in node.names)] + if isinstance(node, ast.Call): + func = node.func + callee = func.id if isinstance(func, ast.Name) else getattr(func, "attr", "") + return [ + name + for keyword, position in DYNAMIC_IMPORT_MODULE_PARAMS.get(callee, ()) + for name in _literal_strings(_call_argument(node, keyword, position)) + ] + return [] + + +def memory_engine_imports(source: str, rel: str) -> List[str]: + """`:: ` for each import in `source` naming the engine. + + A module that does not parse is reported as a hit: an import the guard + cannot see is not one it can vouch for. + """ + try: + tree = ast.parse(source, filename=rel) + except SyntaxError as exc: + return [f"{rel}:{exc.lineno or 0}: unparseable ({exc.msg})"] + lines = source.splitlines() + hits: List[Tuple[int, str]] = [] + for node in ast.walk(tree): + if any(_names_memory_engine(name) for name in _imported_names(node)): + hits.append((node.lineno, lines[node.lineno - 1].strip()[:80])) + return [f"{rel}:{lineno}: {text}" for lineno, text in sorted(set(hits))] + + +def check_memory_boundary(repo_root: Path) -> CheckResult: + """No kernel module imports the memory engine (`memory_*` or `agent_memory`).""" + start = time.monotonic() + hits: List[str] = [] + modules = _iter_kernel_modules(repo_root) + for path in modules: + rel = path.relative_to(repo_root).as_posix() + source = path.read_text(encoding="utf-8", errors="replace") + hits.extend(memory_engine_imports(source, rel)) + if hits: + return CheckResult( + name="memory-boundary", + passed=False, + details="kernel modules importing the memory engine:\n" + "\n".join(hits), + duration_s=time.monotonic() - start, + ) + return CheckResult( + name="memory-boundary", + passed=True, + details=f"{len(modules)} kernel modules import no memory engine", + duration_s=time.monotonic() - start, + ) + + def _discover_repo_files(repo_root: Path, runner: Runner) -> List[Path]: rc, output = runner(["git", "ls-files"], repo_root) if rc == 0: @@ -517,6 +634,7 @@ def run_preflight( check_conflict_markers(repo_root, runner=runner), check_makefile(repo_root), check_packaging_boundary(repo_root), + check_memory_boundary(repo_root), check_yaml_syntax(repo_root, loader=yaml_loader), check_ruff(repo_root, runner=runner), check_shellcheck(repo_root, runner=runner), diff --git a/scripts/promote_to_archive.py b/scripts/promote_to_archive.py deleted file mode 100644 index a6ad3fd..0000000 --- a/scripts/promote_to_archive.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Move a non-standard memory file into memory/archive/.""" - -from __future__ import annotations - -import argparse -import datetime as dt -import json -import sys -from pathlib import Path -from typing import Any, Dict, Optional, Sequence - -from _oacp_env import resolve_oacp_home -from memory_archive_common import ( - ACTIVE_MEMORY_FILES, - build_archive_name, - project_memory_paths, - validate_memory_basename, -) - - -def archive_memory_file( - project_name: str, - memory_file: str, - *, - oacp_root: Path, - dry_run: bool = False, - now: dt.datetime | None = None, -) -> Dict[str, Any]: - validate_memory_basename(memory_file) - if memory_file in ACTIVE_MEMORY_FILES: - raise ValueError(f"cannot archive standard active memory file: {memory_file}") - - _, memory_dir, archive_dir = project_memory_paths(oacp_root, project_name) - source = memory_dir / memory_file - if not source.is_file(): - raise ValueError(f"memory file not found: {source}") - - archived_file = build_archive_name(memory_file, now=now) - destination = archive_dir / archived_file - if destination.exists(): - raise ValueError(f"archive destination already exists: {destination}") - - if not dry_run: - archive_dir.mkdir(parents=True, exist_ok=True) - source.rename(destination) - - return { - "project": project_name, - "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 _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Move a non-standard memory file into memory/archive/." - ) - parser.add_argument("project_name", help="Project workspace name") - parser.add_argument("memory_file", help="Active memory file basename to archive") - parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - parser.add_argument("--dry-run", action="store_true", help="Report actions without renaming") - parser.add_argument("--json", dest="json_output", action="store_true", help="Emit JSON output") - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> int: - args = _build_parser().parse_args(sys.argv[1:] if argv is None else argv) - oacp_root = resolve_oacp_home(explicit=args.oacp_dir) - - try: - result = archive_memory_file( - args.project_name, - args.memory_file, - oacp_root=oacp_root, - dry_run=args.dry_run, - ) - except ValueError as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - if args.json_output: - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - prefix = "Would archive" if args.dry_run else "Archived" - print( - f"{prefix} memory/{result['memory_file']} -> " - f"memory/archive/{result['archived_file']}" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/restore_from_archive.py b/scripts/restore_from_archive.py deleted file mode 100644 index e9da6b7..0000000 --- a/scripts/restore_from_archive.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Restore an archived memory file back into the active memory working set.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any, Dict, Optional, Sequence - -from _oacp_env import resolve_oacp_home -from memory_archive_common import original_name_from_archive, project_memory_paths - - -def restore_memory_file( - project_name: str, - archived_file: str, - *, - oacp_root: Path, - dry_run: bool = False, -) -> Dict[str, Any]: - restored_file = original_name_from_archive(archived_file) - _, memory_dir, archive_dir = project_memory_paths(oacp_root, project_name) - if not archive_dir.is_dir(): - raise ValueError(f"memory archive directory not found: {archive_dir}") - source = archive_dir / archived_file - if not source.is_file(): - raise ValueError(f"archived memory file not found: {source}") - - destination = memory_dir / restored_file - if destination.exists(): - raise ValueError(f"active memory destination already exists: {destination}") - - if not dry_run: - source.rename(destination) - - return { - "project": project_name, - "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", - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Restore an archived memory file into the active working set." - ) - parser.add_argument("project_name", help="Project workspace name") - parser.add_argument("archived_file", help="Archived memory file basename to restore") - parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - parser.add_argument("--dry-run", action="store_true", help="Report actions without renaming") - parser.add_argument("--json", dest="json_output", action="store_true", help="Emit JSON output") - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> int: - args = _build_parser().parse_args(sys.argv[1:] if argv is None else argv) - oacp_root = resolve_oacp_home(explicit=args.oacp_dir) - - try: - result = restore_memory_file( - args.project_name, - args.archived_file, - oacp_root=oacp_root, - dry_run=args.dry_run, - ) - except ValueError as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - if args.json_output: - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - prefix = "Would restore" if args.dry_run else "Restored" - print( - f"{prefix} memory/archive/{result['archived_file']} -> " - f"memory/{result['restored_file']}" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/send_inbox_message.py b/scripts/send_inbox_message.py index d21402c..bd300c8 100644 --- a/scripts/send_inbox_message.py +++ b/scripts/send_inbox_message.py @@ -39,7 +39,7 @@ 1 — validation error 2 — usage error or fatal failure -Reference: docs/protocol/inbox_outbox.md, Issue #68/#77 +Reference: docs/protocol/inbox_outbox.md """ from __future__ import annotations @@ -810,7 +810,7 @@ def main() -> int: parser = argparse.ArgumentParser( description="Compose and send a protocol-compliant agent inbox message.", epilog=( - "Reference: docs/protocol/inbox_outbox.md (Issue #68/#80)\n\n" + "Reference: docs/protocol/inbox_outbox.md\n\n" "Sender resolution order: --from, OACP_AGENT, AGENT_NAME, project agent card runtime match.\n" "Card runtime fallback checks OACP_RUNTIME and runtime-specific environment markers.\n" "Suggested channels: brainstorm, review, deploy, incident\n" diff --git a/scripts/setup_runtime.py b/scripts/setup_runtime.py index ff4b44a..efc4ec3 100644 --- a/scripts/setup_runtime.py +++ b/scripts/setup_runtime.py @@ -6,11 +6,12 @@ from __future__ import annotations import argparse +import hashlib import json import shlex import sys from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Optional, Sequence, Tuple from add_agent import ( CLAUDE_SUPPORTED_MESSAGE_TYPES, @@ -38,12 +39,12 @@ ## Workflow 1. **Load startup context** — after trusting the generated project hook with - `/hooks`, it syncs OACP memory to disk and verifies the required protocol and - project memory files in one ordered `SessionStart` command. Read the files - named in its developer context before normal work and include its - `SESSION_INIT_ACK` in the first response. + `/hooks`, it verifies the required protocol and project memory files in one + `SessionStart` command. Read the files named in its developer context before + normal work and include its `SESSION_INIT_ACK` in the first response. + Memory sync is the memory tool's startup hook (`agent-memory setup codex`). Org memory is retrieved on demand; read applicable org rules and decisions - before work they govern. Memory pull does not load org content into context. + before work they govern. Syncing does not load org content into context. 2. **Check inbox when requested** — surface pending state before processing work. 3. **Send messages** via `oacp send --from codex --to --type --subject "..." --body "..."`. 4. **Update status** in `agents/codex/status.yaml` when starting/finishing tasks. @@ -53,7 +54,7 @@ ```bash oacp doctor --project # health check -oacp session-init --pull-memory --project # manual hook fallback +oacp session-init --project # manual hook fallback oacp send --from codex ... # send a message oacp validate # validate a message ``` @@ -95,7 +96,15 @@ pass --from explicitly when sending OACP messages. """ -CLAUDE_MEMORY_PULL_HOOK = """\ +# Memory hooks belong to the memory tool (`agent-memory setup `). +# Earlier kernels wrote these two Claude hook scripts and registered them; a +# regeneration retires the registrations by exact command and removes the +# files only when their bytes are one of the generated texts, digest for +# digest. Anything else at those paths is somebody's own work and is kept. +MEMORY_TOOL = "agent-memory" +CLAUDE_LEGACY_MEMORY_PULL_COMMAND = ".claude/hooks/oacp-memory-pull.sh" +CLAUDE_LEGACY_MEMORY_PUSH_COMMAND = ".claude/hooks/oacp-memory-push.sh" +CLAUDE_LEGACY_MEMORY_PULL_HOOK = """\ #!/usr/bin/env bash # Claude hook event: SessionStart (startup) set -u @@ -107,20 +116,42 @@ oacp memory pull --oacp-dir "$OACP_ROOT" || true """ +CLAUDE_LEGACY_MEMORY_PUSH_HOOK = """\ +#!/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(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +#: Registrations retired by exact command, per hook event. +CLAUDE_LEGACY_MEMORY_REGISTRATIONS = { + "SessionStart": (CLAUDE_LEGACY_MEMORY_PULL_COMMAND,), + "SessionEnd": (CLAUDE_LEGACY_MEMORY_PUSH_COMMAND,), +} +#: Files removed only when the digest of their raw bytes is one of these, per +#: repo-relative path: a CRLF copy or any edited byte is not the generated file. +CLAUDE_LEGACY_MEMORY_FILES = { + CLAUDE_LEGACY_MEMORY_PULL_COMMAND: ( + _digest(CLAUDE_LEGACY_MEMORY_PULL_HOOK.encode("utf-8")), + ), + CLAUDE_LEGACY_MEMORY_PUSH_COMMAND: ( + _digest(CLAUDE_LEGACY_MEMORY_PUSH_HOOK.encode("utf-8")), + ), +} CLAUDE_SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json" -CLAUDE_LEGACY_MEMORY_PUSH_COMMAND = ".claude/hooks/oacp-memory-push.sh" CLAUDE_HOOK_COMMANDS = { - "SessionStart": { - "matcher": "startup", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/oacp-memory-pull.sh", - "timeout": 30, - } - ], - }, # Static envelope shim: per-task constraints live in the # compiled active_envelope.json, so this settings entry never changes per # dispatch and is a no-op while no envelope is active. @@ -137,16 +168,22 @@ } CODEX_HOOKS_DESCRIPTION = "OACP startup verification for this workspace." +# The managed startup command in either era: the prefix, the retired +# `--pull-memory` flag earlier kernels added, and the generated `--project` / +# `--hub-dir` arguments, each at most once. Only that grammar is replaced on +# regeneration; a command carrying any other token (an extra flag, a shell +# operator, an appended command) is a custom hook and is preserved. CODEX_SESSION_START_COMMAND_PREFIX = ( "oacp", "session-init", "--hook", - "--pull-memory", ) - - -def _make_executable(path: Path) -> None: - path.chmod(path.stat().st_mode | 0o755) +CODEX_LEGACY_PULL_FLAG = "--pull-memory" +CODEX_SESSION_START_VALUE_OPTIONS = ("--project", "--hub-dir") +# A generated option value carries an expansion character only single-quoted, +# the way shlex.join emits it; one spelled any other way was written by hand +# for a shell to expand. +CODEX_SHELL_EXPANSION_CHARS = ("$", "`") def _load_template(relative: str) -> Optional[str]: @@ -171,21 +208,90 @@ def _hook_command_exists(entries: List[Any], command: str) -> bool: return False +def _shell_words(command: str) -> Optional[List[str]]: + """Split like a POSIX shell, with operators as words of their own. + + `shlex.split` keeps `demo;true` as one word; the punctuation-aware lexer + yields `demo`, `;`, `true`, so an attached operator, pipe or redirection + surfaces as a token the generated grammar does not contain. Quoted words + stay whole, so a generated `--hub-dir '/srv/oacp home'` still parses, and + `#` is an ordinary character rather than a comment, as in `shlex.split`. + """ + lexer = shlex.shlex(command, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + lexer.commenters = "" + try: + return list(lexer) + except ValueError: + return None + + def _is_codex_session_start_command(command: Any) -> bool: + """True only for a command the kernel generated, in either era.""" if not isinstance(command, str): return False - try: - argv = shlex.split(command) - except ValueError: + argv = _shell_words(command) + if argv is None: return False + # shlex.join reproduces its own output exactly, so a command that survives + # the round trip carries every `$` and backtick as a quoted literal the + # shell will not expand; the exclusion below is for the spellings that + # do not (`--hub-dir $HOME/x`, `--hub-dir "$HOME/x"`). + literal = shlex.join(argv) == command prefix = list(CODEX_SESSION_START_COMMAND_PREFIX) - return argv[: len(prefix)] == prefix + if argv[: len(prefix)] != prefix: + return False + rest = argv[len(prefix) :] + seen = set() + index = 0 + while index < len(rest): + token = rest[index] + if token in seen: + return False + seen.add(token) + if token == CODEX_LEGACY_PULL_FLAG: + index += 1 + continue + if token in CODEX_SESSION_START_VALUE_OPTIONS and index + 1 < len(rest): + value = rest[index + 1] + if value.startswith("-") or ( + not literal + and any(char in value for char in CODEX_SHELL_EXPANSION_CHARS) + ): + return False + index += 2 + continue + return False + return True + + +def _has_codex_managed_entry(entries: List[Any]) -> bool: + """True when any SessionStart entry already holds a kernel-generated command.""" + for existing in entries: + if not isinstance(existing, dict): + continue + hooks = existing.get("hooks") + if not isinstance(hooks, list): + continue + for hook in hooks: + if isinstance(hook, dict) and _is_codex_session_start_command( + hook.get("command") + ): + return True + return False def _replace_codex_session_start_entry( - entries: List[Any], replacement: Dict[str, Any] + entries: List[Any], + replacement: Dict[str, Any], + *, + create_when_absent: bool = True, ) -> bool: - """Replace all OACP-managed startup hooks while preserving custom entries.""" + """Replace all OACP-managed startup hooks while preserving custom entries. + + With ``create_when_absent`` false, a list holding no managed entry is left + untouched: setup regenerates the entry it owns, it never introduces one. + """ updated_entries: List[Any] = [] replacement_added = False @@ -218,6 +324,8 @@ def _replace_codex_session_start_entry( updated_entries.append(retained_entry) if not replacement_added: + if not create_when_absent: + return False updated_entries.append(replacement) if updated_entries == entries: return False @@ -260,7 +368,7 @@ def _warn_claude_settings(settings_file: Path, message: str) -> None: def _write_claude_memory_settings(repo_dir: Path) -> Optional[bool]: - """Register startup/envelope hooks and retire the generated auto-push hook.""" + """Register the envelope hook and retire the generated memory hook entries.""" settings_file = repo_dir / ".claude" / "settings.json" if settings_file.is_file(): try: @@ -289,13 +397,15 @@ def _write_claude_memory_settings(repo_dir: Path) -> Optional[bool]: return None changed = False - session_end = hooks.get("SessionEnd") - if isinstance(session_end, list) and _remove_hook_command( - session_end, CLAUDE_LEGACY_MEMORY_PUSH_COMMAND - ): - changed = True - if not session_end: - del hooks["SessionEnd"] + for event_name, commands in CLAUDE_LEGACY_MEMORY_REGISTRATIONS.items(): + entries = hooks.get(event_name) + if not isinstance(entries, list): + continue + for command in commands: + if _remove_hook_command(entries, command): + changed = True + if not entries: + del hooks[event_name] for event_name, entry in CLAUDE_HOOK_COMMANDS.items(): entries = hooks.setdefault(event_name, []) @@ -311,18 +421,68 @@ def _write_claude_memory_settings(repo_dir: Path) -> Optional[bool]: changed = True if changed: - settings_file.parent.mkdir(parents=True, exist_ok=True) - settings_file.write_text( - json.dumps(data, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) + try: + settings_file.parent.mkdir(parents=True, exist_ok=True) + settings_file.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + except OSError as exc: + _warn_claude_settings( + settings_file, + f"could not write ({exc.strerror or exc}); skipping hook registration.", + ) + return None return changed +def _retire_claude_legacy_memory_files(repo_dir: Path) -> Tuple[List[str], List[str]]: + """Delete the generated memory hook scripts, byte for byte; keep anything else. + + Runs after the settings file has been written back. Returns (retired, kept): + repo-relative paths removed, and paths that exist but are kept because + their bytes are not the generated text, the settings file still names + them (a registration in a shape the migration does not manage), or the + path reaches them through a symlink. + """ + retired: List[str] = [] + kept: List[str] = [] + settings_file = repo_dir / ".claude" / "settings.json" + try: + settings_text = ( + settings_file.read_text(encoding="utf-8") if settings_file.is_file() else "" + ) + except (OSError, UnicodeDecodeError): + settings_text = None + for relative, digests in CLAUDE_LEGACY_MEMORY_FILES.items(): + path = repo_dir / relative + if not path.is_file(): + continue + if ( + settings_text is None + or relative in settings_text + or path.resolve() != (repo_dir.resolve() / relative) + ): + kept.append(relative) + continue + try: + generated = _digest(path.read_bytes()) in digests + if generated: + path.unlink() + except OSError: + kept.append(relative) + continue + if generated: + retired.append(relative) + else: + kept.append(relative) + return retired, kept + + def _codex_session_start_command( *, project_name: Optional[str], oacp_root: Optional[Path] ) -> str: - argv = ["oacp", "session-init", "--hook", "--pull-memory"] + argv = ["oacp", "session-init", "--hook"] if project_name: argv.extend(["--project", project_name]) if oacp_root is not None: @@ -359,10 +519,17 @@ def _write_codex_hooks( *, project_name: Optional[str], oacp_root: Optional[Path], -) -> Optional[bool]: - """Create or merge the repo-local Codex SessionStart hook definition.""" +) -> Optional[str]: + """Create or regenerate the repo-local Codex SessionStart hook definition. + + Returns ``"created"`` when the file was written, ``"unchanged"`` when the + managed entry was already current, ``"unmanaged"`` when an existing file + carries no managed entry (left byte-identical — setup regenerates the entry + it owns and never adds one), or ``None`` when the file was unreadable. + """ hooks_file = repo_dir / ".codex" / "hooks.json" - if hooks_file.is_file(): + file_existed = hooks_file.is_file() + if file_existed: try: data = json.loads(hooks_file.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -395,18 +562,26 @@ def _write_codex_hooks( ) return None + # An existing hooks file with no managed entry is the on-disk encoding of a + # deliberate "startup stays off" choice; regenerating is setup's job, and + # creating one here would silently reverse that policy. + if file_existed and not _has_codex_managed_entry(entries): + return "unmanaged" + entry = _codex_session_start_entry( project_name=project_name, oacp_root=oacp_root, ) - if not _replace_codex_session_start_entry(entries, entry): - return False + if not _replace_codex_session_start_entry( + entries, entry, create_when_absent=not file_existed + ): + return "unchanged" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text( json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) - return True + return "created" def _detect_repo_root(start: Path) -> Optional[Path]: @@ -469,7 +644,8 @@ def setup_runtime( ) -> Dict[str, Any]: """Generate runtime-specific configuration files. - Returns a dict with ``created_files``, ``skipped_files``, and ``warning_files``. + Returns a dict with ``created_files``, ``skipped_files``, ``unmanaged_files`` + and ``warning_files``. """ if runtime not in CREATABLE_RUNTIMES: raise ValueError( @@ -477,7 +653,9 @@ def setup_runtime( ) created_files: List[str] = [] + retired_files: List[str] = [] skipped_files: List[str] = [] + unmanaged_files: List[str] = [] warning_files: List[str] = [] project_created_files: List[str] = [] project_skipped_files: List[str] = [] @@ -512,13 +690,6 @@ def setup_runtime( else: skipped_files.append(".claude/skills/") - pull_hook = repo_dir / ".claude" / "hooks" / "oacp-memory-pull.sh" - if _write_if_missing(pull_hook, CLAUDE_MEMORY_PULL_HOOK): - _make_executable(pull_hook) - created_files.append(str(pull_hook.relative_to(repo_dir))) - else: - skipped_files.append(str(pull_hook.relative_to(repo_dir))) - settings_file = repo_dir / ".claude" / "settings.json" settings_result = _write_claude_memory_settings(repo_dir) if settings_result is True: @@ -528,6 +699,15 @@ def setup_runtime( else: warning_files.append(str(settings_file.relative_to(repo_dir))) + # The generated hook scripts go only after their registrations are + # out of a settings file that was read, validated and written back + # (or needed no change). A refused or unwritable settings file keeps + # every script beside its still-live registration. + if settings_result is not None: + retired, kept = _retire_claude_legacy_memory_files(repo_dir) + retired_files.extend(retired) + skipped_files.extend(kept) + if project_name: if oacp_root is None: from _oacp_env import resolve_oacp_home @@ -564,10 +744,12 @@ def setup_runtime( project_name=project_name, oacp_root=oacp_root, ) - if hooks_result is True: + if hooks_result == "created": created_files.append(str(hooks_file.relative_to(repo_dir))) - elif hooks_result is False: + elif hooks_result == "unchanged": skipped_files.append(str(hooks_file.relative_to(repo_dir))) + elif hooks_result == "unmanaged": + unmanaged_files.append(str(hooks_file.relative_to(repo_dir))) else: warning_files.append(str(hooks_file.relative_to(repo_dir))) @@ -630,7 +812,9 @@ def setup_runtime( return { "created_files": created_files, + "retired_files": retired_files, "skipped_files": skipped_files, + "unmanaged_files": unmanaged_files, "warning_files": warning_files, "project_created_files": project_created_files, "project_skipped_files": project_skipped_files, @@ -672,9 +856,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int: print(f" + {f}") for f in result["skipped_files"]: print(f" ~ {f} (already exists, skipped)") + for f in result["unmanaged_files"]: + print(f" ~ {f} (no managed entry; skipped)") for f in result["warning_files"]: print(f" ! {f} (warning, skipped)") - if args.runtime == "codex" and ".codex/hooks.json" not in result["warning_files"]: + for f in result["retired_files"]: + print(f" - {f} (retired; memory hooks belong to {MEMORY_TOOL})") + if args.runtime in ("claude", "codex"): + print(f" Memory startup hook: run `{MEMORY_TOOL} setup {args.runtime}` (pip install agent-memory-cli).") + if args.runtime == "codex" and ".codex/hooks.json" not in ( + result["warning_files"] + result["unmanaged_files"] + ): print(" Review and trust the project hook with `/hooks` before relying on it.") if result["project_created_files"] or result["project_skipped_files"]: print(f"Project agent '{args.runtime}' setup in {oacp_root / 'projects' / str(project_name)}:") @@ -684,6 +876,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: print(f" ~ {f} (already exists, skipped)") if ( not result["created_files"] + and not result["retired_files"] and not result["skipped_files"] and not result["warning_files"] and not result["project_created_files"] diff --git a/scripts/write_event.py b/scripts/write_event.py index fae5fc2..08aa355 100644 --- a/scripts/write_event.py +++ b/scripts/write_event.py @@ -163,7 +163,7 @@ def write_event_file(oacp_root: Path, event: dict) -> Path: if not events_dir.is_dir(): raise ValueError( f"Events directory not found: {events_dir}\n" - f"Run `oacp org-memory init` first." + f"Run `agent-memory org init` first." ) path = events_dir / event["filename"] diff --git a/templates/org-memory/decisions.md b/templates/org-memory/decisions.md deleted file mode 100644 index 8057b7b..0000000 --- a/templates/org-memory/decisions.md +++ /dev/null @@ -1,5 +0,0 @@ -# Org Decisions - - - - diff --git a/templates/org-memory/events/.gitkeep b/templates/org-memory/events/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/templates/org-memory/events/20260317-170120-example-api-convention.md b/templates/org-memory/events/20260317-170120-example-api-convention.md deleted file mode 100644 index de66531..0000000 --- a/templates/org-memory/events/20260317-170120-example-api-convention.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -created_at_utc: 2026-03-17T17:01:20Z -date: 2026-03-17 -agent: claude -project: oacp-dev -type: decision -source_ref: debrief-20260317-s76 -related: ["PR #43"] ---- - -Standardized on REST over GraphQL for all public-facing APIs. Internal services may use gRPC where latency requirements justify the complexity. Decision driven by team familiarity and tooling support. diff --git a/templates/org-memory/recent.md b/templates/org-memory/recent.md deleted file mode 100644 index d604ae8..0000000 --- a/templates/org-memory/recent.md +++ /dev/null @@ -1,17 +0,0 @@ -# Org Memory — Rolling Summary - - - - - -## Current State - - - -## Active Decisions - - - -## Standing Rules - - diff --git a/templates/org-memory/rules.md b/templates/org-memory/rules.md deleted file mode 100644 index 71db6d3..0000000 --- a/templates/org-memory/rules.md +++ /dev/null @@ -1,5 +0,0 @@ -# Org Rules - - - - diff --git a/tests/conformance/memory_layout/README.md b/tests/conformance/memory_layout/README.md new file mode 100644 index 0000000..ea559c1 --- /dev/null +++ b/tests/conformance/memory_layout/README.md @@ -0,0 +1,23 @@ +# Memory layout conformance fixture + +The canonical three-tier memory layout specified in +`docs/protocol/org_memory.md`, as data. The kernel scaffolds the per-project +tier against it (`oacp init`); the memory tool +([agent-memory](https://github.com/kiloloop/agent-memory)) scaffolds the org +tier and writes the sync allowlist. Both repositories test against this +fixture, so a layout change is a change to these files first and to the +implementations second. + +- `layout.yaml` — the marker and ignore-file names, the never-synced + directory names, each storage tier (pattern, files, dirs, unsynced dirs), + and `entries`, the literal closure of everything the layout names. +- `canonical_memory_gitignore.txt` — the sync allowlist written to a home's + `.gitignore`, byte-exact. Compare bytes, never lines. + +`tests/test_memory_layout_fixture.py` pins, in both directions, the spec's +"Layout" block and its `.gitignore` block to this fixture, the fixture's +`entries` to the closure of its tiers, the ignore file's rule lines to the +tiers and never-synced names (so the two files here cannot disagree), and +the kernel's project-tier scaffold to its tier. The memory tool vendors these +two files and asserts its own layout table, ignore text, and org-tier scaffold +against them the same way. diff --git a/tests/conformance/memory_layout/canonical_memory_gitignore.txt b/tests/conformance/memory_layout/canonical_memory_gitignore.txt new file mode 100644 index 0000000..d4bde11 --- /dev/null +++ b/tests/conformance/memory_layout/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/conformance/memory_layout/layout.yaml b/tests/conformance/memory_layout/layout.yaml new file mode 100644 index 0000000..c92ded2 --- /dev/null +++ b/tests/conformance/memory_layout/layout.yaml @@ -0,0 +1,71 @@ +# Canonical three-tier memory layout — the vendored fixture that the kernel +# and the memory tool (agent-memory) both test against. +# +# Paths are literal and home-relative. "*" stands for exactly one project +# name; a trailing "/" marks a directory. The spec is docs/protocol/org_memory.md; +# its "Layout" block enumerates the same set, and tests/test_memory_layout_fixture.py +# fails when the two differ in either direction. +schema_version: 1 + +marker_file: .oacp-memory-repo +gitignore_file: .gitignore +projects_dir: projects +project_wildcard: "*" + +# Top-level directory names denied by the sync predicate at any depth, +# whatever the ignore file says. Listed after every allow rule in the +# canonical .gitignore so the deny wins even if the allowlist is widened. +never_synced_dirs: + - keys + +# The storage tiers, in allowlist order. +tiers: + - name: org + pattern: org-memory + files: + - recent.md + - decisions.md + - rules.md + dirs: + - events + - debriefs + unsynced: [] + - name: project + pattern: projects/*/memory + files: + - project_facts.md + - decision_log.md + - open_threads.md + - known_debt.md + dirs: + - archive + unsynced: + - .cache + +# Every entry the layout names, spelled out. This list is the closure of the +# fields above (marker, ignore file, projects dir, and each tier's directory, +# files, dirs, and unsynced dirs); the pinning test checks that it is. +entries: + - .gitignore + - .oacp-memory-repo + - org-memory/ + - org-memory/recent.md + - org-memory/decisions.md + - org-memory/rules.md + - org-memory/events/ + - org-memory/debriefs/ + - projects/ + - projects/*/memory/ + - projects/*/memory/project_facts.md + - projects/*/memory/decision_log.md + - projects/*/memory/open_threads.md + - projects/*/memory/known_debt.md + - projects/*/memory/archive/ + - projects/*/memory/.cache/ + +# The canonical sync allowlist lives beside this file as +# canonical_memory_gitignore.txt (byte-exact; compare bytes, never lines). +# Its rule lines, comments aside, are the fields above in allowlist order +# (allow rules, each tier's unsynced dirs, never_synced_dirs last); the +# pinning test derives them and fails when the two files disagree. +gitignore_golden: canonical_memory_gitignore.txt diff --git a/tests/conformance/org_memory/README.md b/tests/conformance/org_memory/README.md deleted file mode 100644 index 267e161..0000000 --- a/tests/conformance/org_memory/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Org-memory debrief store conformance corpus - -Pinned decision contract for `oacp doctor`'s Org Memory category -(`check_org_memory` in `scripts/oacp_doctor.py`), which checks the -setup of the central debrief store specified in -`docs/protocol/org_memory.md` → "Debrief Store": directory presence, -canonical path layout, lingering staging artifacts, and irregular -entries. The doctor never opens debrief files — content and format -verification belong to the writer contract and git history, so no case -here exercises file contents. - -Each case under `cases//` holds: - -- `org-memory/` — a miniature store tree copied into a temp OACP home - by the runner (`tests/test_org_memory_doctor.py`). -- `expected.yaml` — the exact set of non-ok findings the checker must - emit (`findings: []` means the store must validate clean), each as - `name` + `severity` + optional `message_contains`. - -Finding names and severities here are pinned: changing them is a -behavior change to doctor's output contract and must update this corpus -in the same commit. diff --git a/tests/conformance/org_memory/cases/bad_layout/expected.yaml b/tests/conformance/org_memory/cases/bad_layout/expected.yaml deleted file mode 100644 index a794d78..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/expected.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 3b84380..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 5c6f0ee..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 8e28157..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 9fda75f..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 5701bc4..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 9fda75f..0000000 --- a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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.yaml b/tests/conformance/org_memory/cases/empty_store/expected.yaml deleted file mode 100644 index b7c9448..0000000 --- a/tests/conformance/org_memory/cases/empty_store/expected.yaml +++ /dev/null @@ -1,2 +0,0 @@ -description: Fresh-init store — .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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml b/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml deleted file mode 100644 index 2d2cef6..0000000 --- a/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 26fb610..0000000 --- a/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +++ /dev/null @@ -1 +0,0 @@ -# Recent diff --git a/tests/conformance/org_memory/cases/staging_artifact/expected.yaml b/tests/conformance/org_memory/cases/staging_artifact/expected.yaml deleted file mode 100644 index d0a4b6e..0000000 --- a/tests/conformance/org_memory/cases/staging_artifact/expected.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: A lingering writer staging artifact beside a valid record — 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 deleted file mode 100644 index 72d4134..0000000 --- a/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index b222f13..0000000 --- a/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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.yaml b/tests/conformance/org_memory/cases/valid_store/expected.yaml deleted file mode 100644 index 025065e..0000000 --- a/tests/conformance/org_memory/cases/valid_store/expected.yaml +++ /dev/null @@ -1,2 +0,0 @@ -description: Fully conforming store — 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 deleted file mode 100644 index e69de29..0000000 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 deleted file mode 100644 index 2370740..0000000 --- a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 5c6f0ee..0000000 --- a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 1d94a01..0000000 --- a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 459d303..0000000 --- a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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/test_codex_session_init.py b/tests/test_codex_session_init.py index 3ab61a6..abe43cc 100644 --- a/tests/test_codex_session_init.py +++ b/tests/test_codex_session_init.py @@ -439,7 +439,7 @@ def test_hook_output_is_bounded_and_truthful(self) -> None: ) output = build_session_start_hook_output( report, - memory_sync={"state": "ok", "messages": ["already synced"]}, + memory_pull={"state": "ok", "messages": ["already synced"]}, ) context = output["hookSpecificOutput"]["additionalContext"] @@ -619,7 +619,7 @@ def test_hook_runtime_error_degrades_without_blocking(self) -> None: self.assertIn("degraded mode", payload["systemMessage"]) context = payload["hookSpecificOutput"]["additionalContext"] self.assertIn("PermissionError", context) - self.assertIn("session-init --pull-memory", context) + self.assertIn("run `oacp session-init` manually", context) def test_large_org_memory_does_not_change_init_reads_or_context(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -700,5 +700,80 @@ def test_archive_dir_is_not_loaded_during_session_init(self) -> None: self.assertNotIn("20260320T000000Z_notes.md", report["memory"]) +class TestPullMemoryReport(unittest.TestCase): + """The startup pull runs the memory tool as a subprocess, never in-process.""" + + def _hub(self, td: str, *, marker: bool) -> Path: + hub_dir = Path(td) + if marker: + (hub_dir / ".oacp-memory-repo").write_text("", encoding="utf-8") + return hub_dir + + def test_no_marker_is_disabled_without_looking_for_the_tool(self) -> None: + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.shutil, "which" + ) as which, mock.patch.object(session_init.subprocess, "run") as run: + report = session_init._pull_memory_report(hub_dir=self._hub(td, marker=False), dry_run=False) + self.assertEqual(report["state"], "disabled") + which.assert_not_called() + run.assert_not_called() + + def test_dry_run_skips_the_tool(self) -> None: + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.subprocess, "run" + ) as run: + report = session_init._pull_memory_report(hub_dir=self._hub(td, marker=True), dry_run=True) + self.assertEqual(report["state"], "dry-run") + run.assert_not_called() + + def test_tool_absent_is_disabled_with_the_install_hint(self) -> None: + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.shutil, "which", return_value=None + ), mock.patch.object(session_init.subprocess, "run") as run: + report = session_init._pull_memory_report(hub_dir=self._hub(td, marker=True), dry_run=False) + self.assertEqual(report["state"], "disabled") + self.assertEqual(len(report["messages"]), 1) + self.assertIn("agent-memory is not on PATH", report["messages"][0]) + self.assertIn("pip install agent-memory-cli", report["messages"][0]) + run.assert_not_called() + + def test_tool_present_runs_pull_with_home(self) -> None: + completed = mock.Mock(returncode=0, stdout="memory pull: already synced.\n", stderr="") + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.shutil, "which", return_value="/opt/bin/agent-memory" + ), mock.patch.object(session_init.subprocess, "run", return_value=completed) as run: + hub_dir = self._hub(td, marker=True) + report = session_init._pull_memory_report(hub_dir=hub_dir, dry_run=False) + self.assertEqual(report, {"state": "ok", "messages": ["memory pull: already synced."]}) + argv = run.call_args.args[0] + self.assertEqual(argv, ["/opt/bin/agent-memory", "pull", "--home", str(hub_dir)]) + self.assertEqual(run.call_args.kwargs["timeout"], session_init.MEMORY_PULL_TIMEOUT_SECONDS) + + def test_nonzero_exit_is_failed_with_the_tool_output(self) -> None: + completed = mock.Mock(returncode=1, stdout="", stderr="memory pull: diverged from upstream; resolve manually.\n") + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.shutil, "which", return_value="/opt/bin/agent-memory" + ), mock.patch.object(session_init.subprocess, "run", return_value=completed): + report = session_init._pull_memory_report(hub_dir=self._hub(td, marker=True), dry_run=False) + self.assertEqual(report["state"], "failed") + self.assertEqual(report["messages"], ["memory pull: diverged from upstream; resolve manually."]) + + def test_warning_lines_downgrade_to_warning(self) -> None: + completed = mock.Mock(returncode=0, stdout="WARNING: fetch failed; local copy kept.\n", stderr="") + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.shutil, "which", return_value="/opt/bin/agent-memory" + ), mock.patch.object(session_init.subprocess, "run", return_value=completed): + report = session_init._pull_memory_report(hub_dir=self._hub(td, marker=True), dry_run=False) + self.assertEqual(report["state"], "warning") + + def test_launch_failure_is_failed_not_raised(self) -> None: + with tempfile.TemporaryDirectory() as td, mock.patch.object( + session_init.shutil, "which", return_value="/opt/bin/agent-memory" + ), mock.patch.object(session_init.subprocess, "run", side_effect=OSError("exec format error")): + report = session_init._pull_memory_report(hub_dir=self._hub(td, marker=True), dry_run=False) + self.assertEqual(report["state"], "failed") + self.assertIn("exec format error", report["messages"][0]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_init_org_memory.py b/tests/test_init_org_memory.py deleted file mode 100644 index fdaa76a..0000000 --- a/tests/test_init_org_memory.py +++ /dev/null @@ -1,46 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Tests for the org-memory initializer.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -from init_org_memory import initialize_org_memory # noqa: E402 - - -def test_init_creates_full_layout(tmp_path: Path) -> None: - result = initialize_org_memory(tmp_path) - - org = tmp_path / "org-memory" - assert (org / "events").is_dir() - assert (org / "debriefs").is_dir() - assert (org / "debriefs" / ".gitkeep").is_file() - for scaffold in ("recent.md", "decisions.md", "rules.md"): - assert (org / scaffold).is_file() - assert "debriefs/.gitkeep" in result["created"] - - -def test_init_is_idempotent(tmp_path: Path) -> None: - initialize_org_memory(tmp_path) - result = initialize_org_memory(tmp_path) - - assert result["created"] == [] - assert "debriefs/.gitkeep" in result["skipped"] - - -def test_init_backfills_debriefs_on_existing_store(tmp_path: Path) -> None: - # An org-memory tree created before the debrief store existed gains - # debriefs/ on re-init without touching existing content. - org = tmp_path / "org-memory" - (org / "events").mkdir(parents=True) - (org / "recent.md").write_text("# Recent\nexisting\n", encoding="utf-8") - - result = initialize_org_memory(tmp_path) - - assert (org / "debriefs" / ".gitkeep").is_file() - assert (org / "recent.md").read_text(encoding="utf-8") == "# Recent\nexisting\n" - assert "recent.md" in result["skipped"] diff --git a/tests/test_memory_archive.py b/tests/test_memory_archive.py deleted file mode 100644 index 84ee7fe..0000000 --- a/tests/test_memory_archive.py +++ /dev/null @@ -1,445 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Tests for project memory archive/restore tooling.""" - -from __future__ import annotations - -import contextlib -import datetime as dt -import io -import json -import os -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -from memory_archive_common import build_archive_name # noqa: E402 -from memory_sync import CANONICAL_MEMORY_GITIGNORE, is_allowed_memory_path # noqa: E402 -from memory_cli import main as memory_main # noqa: E402 -from promote_to_archive import archive_memory_file # noqa: E402 -from restore_from_archive import restore_memory_file # noqa: E402 - - -def _make_project_root(tmp: str) -> tuple[Path, Path]: - oacp_root = Path(tmp) / "oacp" - project_root = oacp_root / "projects" / "demo" - (project_root / "memory" / "archive").mkdir(parents=True, exist_ok=True) - return oacp_root, project_root - - -class TestMemoryArchiveScripts(unittest.TestCase): - def _git(self, cwd: Path, *args: str) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", *args], - cwd=str(cwd), - capture_output=True, - text=True, - check=False, - ) - - @contextlib.contextmanager - def _git_identity(self): - with mock.patch.dict( - os.environ, - { - "GIT_AUTHOR_NAME": "OACP Test", - "GIT_AUTHOR_EMAIL": "oacp-test@example.com", - "GIT_COMMITTER_NAME": "OACP Test", - "GIT_COMMITTER_EMAIL": "oacp-test@example.com", - "GIT_CONFIG_GLOBAL": os.devnull, - "GIT_CONFIG_SYSTEM": os.devnull, - "OACP_AGENT": "codex", - }, - ): - yield - - def test_memory_cli_init_writes_marker_allowlist_and_initial_commit(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - stdout = io.StringIO() - - with self._git_identity(), contextlib.redirect_stdout(stdout): - code = memory_main(["init", "--oacp-dir", str(oacp_root)]) - - self.assertEqual(code, 0) - self.assertTrue((oacp_root / ".git").is_dir()) - self.assertTrue((oacp_root / ".oacp-memory-repo").is_file()) - self.assertEqual( - (oacp_root / ".gitignore").read_text(encoding="utf-8"), - CANONICAL_MEMORY_GITIGNORE, - ) - log = self._git(oacp_root, "log", "-1", "--format=%s") - self.assertEqual(log.returncode, 0) - self.assertIn("memory: codex@", log.stdout) - - def test_memory_pull_noops_silently_without_marker(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - oacp_root.mkdir() - stdout = io.StringIO() - - with contextlib.redirect_stdout(stdout): - code = memory_main(["pull", "--oacp-dir", str(oacp_root)]) - - self.assertEqual(code, 0) - self.assertEqual(stdout.getvalue(), "") - - def test_memory_push_adds_allowlist_paths_only(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - with self._git_identity(): - self.assertEqual(memory_main(["init", "--oacp-dir", str(oacp_root)]), 0) - (oacp_root / "org-memory").mkdir() - (oacp_root / "org-memory" / "recent.md").write_text( - "# recent\n", - encoding="utf-8", - ) - memory_dir = oacp_root / "projects" / "demo" / "memory" - memory_dir.mkdir(parents=True) - (memory_dir / "project_facts.md").write_text("# facts\n", encoding="utf-8") - agent_dir = oacp_root / "projects" / "demo" / "agents" / "codex" - agent_dir.mkdir(parents=True) - (agent_dir / "status.yaml").write_text("status: busy\n", encoding="utf-8") - - stdout = io.StringIO() - with contextlib.redirect_stdout(stdout): - code = memory_main(["push", "--oacp-dir", str(oacp_root)]) - - self.assertEqual(code, 0) - files = self._git(oacp_root, "ls-files") - self.assertEqual(files.returncode, 0) - tracked = set(files.stdout.splitlines()) - self.assertIn("org-memory/recent.md", tracked) - self.assertIn("projects/demo/memory/project_facts.md", tracked) - self.assertNotIn("projects/demo/agents/codex/status.yaml", tracked) - self.assertNotIn("uncommitted memory changes", stdout.getvalue()) - - def test_memory_push_refuses_diverged_repo_without_committing(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - remote = root / "memory.git" - repo_a = root / "a" - repo_b = root / "b" - - self.assertEqual(self._git(root, "init", "--bare", str(remote)).returncode, 0) - self.assertEqual(self._git(root, "clone", str(remote), str(repo_a)).returncode, 0) - with self._git_identity(), contextlib.redirect_stdout(io.StringIO()): - self.assertEqual( - memory_main(["init", "--remote", str(remote), "--oacp-dir", str(repo_a)]), - 0, - ) - self.assertEqual(self._git(root, "clone", str(remote), str(repo_b)).returncode, 0) - - with self._git_identity(): - (repo_a / "org-memory").mkdir(exist_ok=True) - (repo_a / "org-memory" / "remote.md").write_text( - "# remote\n", - encoding="utf-8", - ) - self.assertEqual(self._git(repo_a, "add", "org-memory/remote.md").returncode, 0) - self.assertEqual(self._git(repo_a, "commit", "-m", "remote memory").returncode, 0) - self.assertEqual(self._git(repo_a, "push").returncode, 0) - - (repo_b / "org-memory").mkdir(exist_ok=True) - (repo_b / "org-memory" / "local.md").write_text("# local\n", encoding="utf-8") - self.assertEqual(self._git(repo_b, "add", "org-memory/local.md").returncode, 0) - self.assertEqual(self._git(repo_b, "commit", "-m", "local memory").returncode, 0) - before = self._git(repo_b, "rev-list", "--count", "HEAD").stdout.strip() - (repo_b / "org-memory" / "uncommitted.md").write_text( - "# uncommitted\n", - encoding="utf-8", - ) - - stdout = io.StringIO() - with contextlib.redirect_stdout(stdout): - code = memory_main(["push", "--oacp-dir", str(repo_b)]) - - after = self._git(repo_b, "rev-list", "--count", "HEAD").stdout.strip() - self.assertEqual(code, 1) - self.assertEqual(before, after) - self.assertIn("diverged", stdout.getvalue()) - - def test_memory_push_noops_silently_without_marker(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - oacp_root.mkdir() - stdout = io.StringIO() - - with contextlib.redirect_stdout(stdout): - code = memory_main(["push", "--oacp-dir", str(oacp_root)]) - - self.assertEqual(code, 0) - self.assertEqual(stdout.getvalue(), "") - - def test_is_allowed_memory_path_excludes_cache_entry(self) -> None: - self.assertFalse(is_allowed_memory_path("projects/demo/memory/.cache")) - self.assertFalse(is_allowed_memory_path("projects/demo/memory/.cache/item.md")) - self.assertTrue(is_allowed_memory_path("projects/demo/memory/project_facts.md")) - - def test_memory_clone_refuses_non_empty_target_without_force(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - oacp_root.mkdir() - (oacp_root / "workspace.json").write_text("{}", encoding="utf-8") - stderr = io.StringIO() - - with contextlib.redirect_stderr(stderr): - code = memory_main( - ["clone", "https://example.invalid/memory.git", "--oacp-dir", str(oacp_root)] - ) - - self.assertEqual(code, 1) - self.assertIn("Refusing to clone into non-empty OACP_HOME", stderr.getvalue()) - - def test_memory_clone_force_restores_target_when_clone_fails(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - oacp_root.mkdir() - (oacp_root / "workspace.json").write_text("{}", encoding="utf-8") - missing_remote = Path(tmp) / "missing.git" - stderr = io.StringIO() - - with contextlib.redirect_stderr(stderr): - code = memory_main( - ["clone", str(missing_remote), "--force", "--oacp-dir", str(oacp_root)] - ) - - self.assertEqual(code, 1) - self.assertTrue((oacp_root / "workspace.json").is_file()) - - def test_memory_init_adds_origin_when_other_remote_exists(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - remote = Path(tmp) / "memory.git" - other_remote = Path(tmp) / "other.git" - self.assertEqual(self._git(Path(tmp), "init", "--bare", str(remote)).returncode, 0) - self.assertEqual( - self._git(Path(tmp), "init", "--bare", str(other_remote)).returncode, - 0, - ) - oacp_root.mkdir() - self.assertEqual(self._git(oacp_root, "init").returncode, 0) - self.assertEqual( - self._git(oacp_root, "remote", "add", "upstream", str(other_remote)).returncode, - 0, - ) - - with self._git_identity(), contextlib.redirect_stdout(io.StringIO()): - code = memory_main( - ["init", "--remote", str(remote), "--oacp-dir", str(oacp_root)] - ) - - self.assertEqual(code, 0) - origin = self._git(oacp_root, "remote", "get-url", "origin") - self.assertEqual(origin.stdout.strip(), str(remote)) - - def test_memory_disable_removes_marker_but_keeps_git_dir(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - with self._git_identity(), contextlib.redirect_stdout(io.StringIO()): - self.assertEqual(memory_main(["init", "--oacp-dir", str(oacp_root)]), 0) - - stdout = io.StringIO() - with contextlib.redirect_stdout(stdout): - code = memory_main(["disable", "--oacp-dir", str(oacp_root)]) - - self.assertEqual(code, 0) - self.assertFalse((oacp_root / ".oacp-memory-repo").exists()) - self.assertTrue((oacp_root / ".git").is_dir()) - - def test_archive_memory_file_moves_into_archive(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - source = project_root / "memory" / "notes.md" - source.write_text("# notes\n", encoding="utf-8") - fixed_now = dt.datetime(2026, 3, 20, 1, 2, 3, tzinfo=dt.timezone.utc) - - result = archive_memory_file( - "demo", - "notes.md", - oacp_root=oacp_root, - now=fixed_now, - ) - - destination = project_root / "memory" / "archive" / result["archived_file"] - self.assertEqual(result["status"], "archived") - self.assertFalse(source.exists()) - self.assertTrue(destination.is_file()) - self.assertEqual(result["archived_file"], "20260320T010203Z_notes.md") - - def test_restore_memory_file_moves_back_to_active_memory(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - archived = project_root / "memory" / "archive" / "20260320T010203Z_notes.md" - archived.write_text("# archived\n", encoding="utf-8") - - result = restore_memory_file( - "demo", - "20260320T010203Z_notes.md", - oacp_root=oacp_root, - ) - - destination = project_root / "memory" / result["restored_file"] - self.assertEqual(result["status"], "restored") - self.assertFalse(archived.exists()) - self.assertTrue(destination.is_file()) - self.assertEqual(result["restored_file"], "notes.md") - - def test_archive_rejects_missing_source(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, _ = _make_project_root(tmp) - with self.assertRaisesRegex(ValueError, "memory file not found"): - archive_memory_file("demo", "notes.md", oacp_root=oacp_root) - - def test_archive_rejects_destination_collision(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - source = project_root / "memory" / "notes.md" - source.write_text("# notes\n", encoding="utf-8") - fixed_now = dt.datetime(2026, 3, 20, 1, 2, 3, tzinfo=dt.timezone.utc) - archived_name = build_archive_name("notes.md", now=fixed_now) - (project_root / "memory" / "archive" / archived_name).write_text( - "# existing\n", encoding="utf-8" - ) - - with self.assertRaisesRegex(ValueError, "archive destination already exists"): - archive_memory_file( - "demo", - "notes.md", - oacp_root=oacp_root, - now=fixed_now, - ) - - def test_restore_rejects_existing_active_destination(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - (project_root / "memory" / "archive" / "20260320T010203Z_notes.md").write_text( - "# archived\n", encoding="utf-8" - ) - (project_root / "memory" / "notes.md").write_text("# current\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "active memory destination already exists"): - restore_memory_file( - "demo", - "20260320T010203Z_notes.md", - oacp_root=oacp_root, - ) - - def test_restore_rejects_missing_archive_directory(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root = Path(tmp) / "oacp" - project_root = oacp_root / "projects" / "demo" - (project_root / "memory").mkdir(parents=True, exist_ok=True) - - with self.assertRaisesRegex(ValueError, "memory archive directory not found"): - restore_memory_file( - "demo", - "20260320T010203Z_notes.md", - oacp_root=oacp_root, - ) - - def test_archive_rejects_path_traversal(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, _ = _make_project_root(tmp) - with self.assertRaisesRegex(ValueError, "simple basename"): - archive_memory_file("demo", "../notes.md", oacp_root=oacp_root) - - def test_archive_rejects_project_path_traversal(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, _ = _make_project_root(tmp) - with self.assertRaisesRegex( - ValueError, "project name must not contain path separators" - ): - archive_memory_file("../demo", "notes.md", oacp_root=oacp_root) - - def test_restore_rejects_invalid_archive_name(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, _ = _make_project_root(tmp) - with self.assertRaisesRegex(ValueError, "must match _"): - restore_memory_file("demo", "notes.md", oacp_root=oacp_root) - - def test_archive_rejects_standard_active_memory_files(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - (project_root / "memory" / "known_debt.md").write_text("# debt\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "cannot archive standard active memory file"): - archive_memory_file("demo", "known_debt.md", oacp_root=oacp_root) - - def test_archive_dry_run_makes_no_changes(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - source = project_root / "memory" / "notes.md" - source.write_text("# notes\n", encoding="utf-8") - fixed_now = dt.datetime(2026, 3, 20, 1, 2, 3, tzinfo=dt.timezone.utc) - - result = archive_memory_file( - "demo", - "notes.md", - oacp_root=oacp_root, - dry_run=True, - now=fixed_now, - ) - - destination = project_root / "memory" / "archive" / result["archived_file"] - self.assertEqual(result["status"], "dry-run") - self.assertTrue(source.is_file()) - self.assertFalse(destination.exists()) - - def test_memory_cli_archive_json_output(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - (project_root / "memory" / "notes.md").write_text("# notes\n", encoding="utf-8") - stdout = io.StringIO() - - with contextlib.redirect_stdout(stdout): - code = memory_main( - [ - "archive", - "demo", - "notes.md", - "--oacp-dir", - str(oacp_root), - "--json", - ] - ) - - payload = json.loads(stdout.getvalue()) - self.assertEqual(code, 0) - self.assertEqual(payload["action"], "archive") - self.assertRegex(payload["archived_file"], r"^\d{8}T\d{6}Z_notes\.md$") - - def test_memory_cli_restore_json_output(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - oacp_root, project_root = _make_project_root(tmp) - archived_name = "20260320T010203Z_notes.md" - (project_root / "memory" / "archive" / archived_name).write_text( - "# archived\n", encoding="utf-8" - ) - stdout = io.StringIO() - - with contextlib.redirect_stdout(stdout): - code = memory_main( - [ - "restore", - "demo", - archived_name, - "--oacp-dir", - str(oacp_root), - "--json", - ] - ) - - payload = json.loads(stdout.getvalue()) - self.assertEqual(code, 0) - self.assertEqual(payload["action"], "restore") - self.assertEqual(payload["restored_file"], "notes.md") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_memory_layout_fixture.py b/tests/test_memory_layout_fixture.py new file mode 100644 index 0000000..bab9970 --- /dev/null +++ b/tests/test_memory_layout_fixture.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Pin the memory layout spec to the vendored conformance fixture. + +The spec (docs/protocol/org_memory.md) enumerates the layout in a fenced +``oacp-memory-layout`` block and the sync allowlist in a fenced ``gitignore`` +block. The fixture (tests/conformance/memory_layout/) carries the same set as +data. This module holds them to each other in both directions, holds the +fixture to its own closure and to the golden's rule lines, and holds the +kernel's project-tier scaffolder to the fixture. The org tier and the sync +allowlist are scaffolded by the memory tool, which vendors this fixture and +pins itself to it the same way. +""" + +from __future__ import annotations + +import copy +import re +import sys +import tempfile +from pathlib import Path +from typing import Dict, List, Set + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from init_project_workspace import initialize_workspace # noqa: E402 + +SPEC = REPO_ROOT / "docs" / "protocol" / "org_memory.md" +FIXTURE_DIR = REPO_ROOT / "tests" / "conformance" / "memory_layout" +FIXTURE = FIXTURE_DIR / "layout.yaml" + + +@pytest.fixture(scope="module") +def fixture() -> Dict: + return yaml.safe_load(FIXTURE.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def golden_gitignore(fixture: Dict) -> bytes: + return (FIXTURE_DIR / fixture["gitignore_golden"]).read_bytes() + + +@pytest.fixture(scope="module") +def spec_text() -> str: + return SPEC.read_text(encoding="utf-8") + + +def _fenced_block(text: str, language: str) -> str: + blocks = re.findall(rf"^```{re.escape(language)}\n(.*?)^```$", text, re.S | re.M) + assert len(blocks) == 1, f"expected exactly one ```{language} block in the spec, found {len(blocks)}" + return blocks[0] + + +def _tier(fixture: Dict, name: str) -> Dict: + return next(tier for tier in fixture["tiers"] if tier["name"] == name) + + +def _closure(fixture: Dict) -> Set[str]: + entries = {fixture["gitignore_file"], fixture["marker_file"], fixture["projects_dir"] + "/"} + for tier in fixture["tiers"]: + root = tier["pattern"] + entries.add(root + "/") + entries.update(f"{root}/{name}" for name in tier["files"]) + entries.update(f"{root}/{name}/" for name in tier["dirs"] + tier["unsynced"]) + return entries + + +# --- fixture ↔ itself ------------------------------------------------------- + + +def test_fixture_entries_are_the_closure_of_its_tiers(fixture: Dict) -> None: + assert set(fixture["entries"]) == _closure(fixture) + assert len(fixture["entries"]) == len(set(fixture["entries"])) + + +def test_fixture_wildcard_appears_only_in_the_project_pattern(fixture: Dict) -> None: + wildcard = fixture["project_wildcard"] + for tier in fixture["tiers"]: + parts = tier["pattern"].split("/") + if tier["name"] == "project": + assert parts == [fixture["projects_dir"], wildcard, "memory"] + else: + assert wildcard not in parts + + +# --- fixture ↔ golden ------------------------------------------------------- + + +def _rules(fixture: Dict) -> List[str]: + """The ignore file's rule lines, derived from the fixture fields in allowlist order. + + Allow rules first (deny everything, re-allow directories, the ignore file, + the marker, each tier's subtree), then the denies that must win over them + (each tier's unsynced dirs, then the never-synced names last). Comment + lines are prose and stay out of the derivation. + """ + allow = ["*", "!*/", "!" + fixture["gitignore_file"], "!" + fixture["marker_file"]] + allow += ["!" + tier["pattern"] + "/**" for tier in fixture["tiers"]] + deny = [f"{tier['pattern']}/{name}/" for tier in fixture["tiers"] for name in tier["unsynced"]] + deny += [name + "/" for name in fixture["never_synced_dirs"]] + return allow + deny + + +def _golden_rules(golden: bytes) -> List[str]: + return [line for line in golden.decode("utf-8").splitlines() if line and not line.startswith("#")] + + +def test_golden_rules_are_derived_from_the_fixture(fixture: Dict, golden_gitignore: bytes) -> None: + assert _golden_rules(golden_gitignore) == _rules(fixture), ( + "canonical .gitignore rules and the fixture's tiers / unsynced / never_synced_dirs differ " + "(order is part of the contract: allow rules, per-tier denies, never-synced names last)" + ) + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param(lambda f: f["never_synced_dirs"].append("credentials"), id="never-synced-name-added"), + pytest.param(lambda f: f["never_synced_dirs"].clear(), id="never-synced-name-removed"), + pytest.param(lambda f: _tier(f, "project")["unsynced"].append("tmp"), id="project-unsynced-dir-added"), + pytest.param(lambda f: _tier(f, "org")["unsynced"].append(".cache"), id="org-unsynced-dir-added"), + pytest.param(lambda f: f["tiers"].reverse(), id="tier-order-changed"), + pytest.param(lambda f: f.update(marker_file=".memory-repo"), id="marker-renamed"), + ], +) +def test_a_fixture_that_contradicts_the_golden_is_caught(fixture: Dict, golden_gitignore: bytes, mutate) -> None: + mutated = copy.deepcopy(fixture) + mutate(mutated) + assert _rules(mutated) != _golden_rules(golden_gitignore) + + +# --- spec ↔ fixture --------------------------------------------------------- + + +def test_spec_layout_block_enumerates_exactly_the_fixture(spec_text: str, fixture: Dict) -> None: + listed = [line for line in _fenced_block(spec_text, "oacp-memory-layout").splitlines() if line.strip()] + assert listed == fixture["entries"], "spec Layout block and fixture entries differ (order is part of the contract)" + + +def test_spec_gitignore_block_is_the_golden_bytes(spec_text: str, golden_gitignore: bytes) -> None: + assert _fenced_block(spec_text, "gitignore").encode("utf-8") == golden_gitignore + + +_PATH_TOKEN = re.compile( + r"`(?:\$OACP_HOME/)?((?:org-memory|projects)/[^`\s]*|\.oacp-memory-repo|\.gitignore|keys/)`" +) + + +def _resolves(token: str, fixture: Dict, entries: Set[str]) -> bool: + path = token.replace("", fixture["project_wildcard"]) + path = re.sub(r"<[^>]+>", "x", path) # other placeholders stand for one segment + path = path.replace("…", "x").replace("...", "x") + if path.rstrip("/") + "/" == "keys/": + return "keys" in fixture["never_synced_dirs"] + if path in entries or path + "/" in entries: + return True + # A path under a fixture directory (e.g. a debrief file under org-memory/debriefs/). + # The bare projects dir is not a prefix candidate: its non-memory children are not layout. + container = fixture["projects_dir"] + "/" + return any(entry.endswith("/") and entry != container and path.startswith(entry) for entry in entries) + + +def test_every_path_the_spec_names_is_in_the_fixture(spec_text: str, fixture: Dict) -> None: + entries = set(fixture["entries"]) + unresolved = sorted({tok for tok in _PATH_TOKEN.findall(spec_text) if not _resolves(tok, fixture, entries)}) + assert not unresolved, f"spec names paths outside the fixture: {unresolved}" + + +def test_every_fixture_entry_is_named_by_the_spec(spec_text: str, fixture: Dict) -> None: + # The Layout block covers the set; each entry must also surface in prose or + # a tree somewhere else in the spec, so the grammar is never a bare list. + prose = spec_text.replace(_fenced_block(spec_text, "oacp-memory-layout"), "") + missing = [ + entry + for entry in fixture["entries"] + if entry.rstrip("/").split("/")[-1] not in prose + ] + assert not missing, f"fixture entries the spec never mentions outside the Layout block: {missing}" + + +# --- kernel ↔ fixture ------------------------------------------------------- + + +def _listing(root: Path) -> List[str]: + return sorted(p.name + ("/" if p.is_dir() else "") for p in root.iterdir() if p.name != ".gitkeep") + + +def test_oacp_init_scaffolds_exactly_the_project_tier(fixture: Dict) -> None: + tier = _tier(fixture, "project") + with tempfile.TemporaryDirectory() as tmpdir: + result = initialize_workspace("demo", oacp_root=Path(tmpdir)) + memory = Path(result["project_root"]) / "memory" + assert memory == Path(tmpdir) / tier["pattern"].replace(fixture["project_wildcard"], "demo") + assert _listing(memory) == sorted(tier["files"] + [d + "/" for d in tier["dirs"]]) diff --git a/tests/test_memory_shim.py b/tests/test_memory_shim.py new file mode 100644 index 0000000..c3bbf57 --- /dev/null +++ b/tests/test_memory_shim.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""The `oacp memory` / `oacp org-memory` exec shim, end to end. + +A stub `agent-memory` on PATH records the argv it was exec'd with, so the +passthrough, the `--oacp-dir` -> `--home` rewrite, and the `init` -> `enable` +map are observed through a real `os.execvp`, not a mock. The absent-tool +path is exercised with a PATH that holds no `agent-memory` at all. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Sequence + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from oacp.cli import DELEGATED_COMMANDS, delegated_argv # noqa: E402 + +# The shebang is this interpreter by absolute path: the test PATH holds only the +# stub directory and the interpreter directory, which need not spell `python3`. +STUB = """\ +#!__PYTHON__ +import json, os, sys +with open(os.environ["STUB_ARGV_FILE"], "w", encoding="utf-8") as fh: + json.dump({"argv": sys.argv, "stdout": "stub ran"}, fh) +print("stub ran") +sys.exit(int(os.environ.get("STUB_EXIT", "0"))) +""" + + +def _run_oacp(args: Sequence[str], *, path: str, env_extra: dict) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if k not in {"PYTHONPATH"}} + env.update({"PATH": path, "PYTHONPATH": str(REPO_ROOT)}) + env.update(env_extra) + return subprocess.run( + [sys.executable, "-m", "oacp.cli", *args], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + check=False, + ) + + +@pytest.fixture +def stub_tool(tmp_path: Path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stub = bin_dir / "agent-memory" + stub.write_text(STUB.replace("__PYTHON__", sys.executable), encoding="utf-8") + stub.chmod(0o755) + argv_file = tmp_path / "argv.json" + python_dir = str(Path(sys.executable).parent) + + def run(args: Sequence[str], **env_extra: str) -> subprocess.CompletedProcess: + return _run_oacp( + args, + path=os.pathsep.join([str(bin_dir), python_dir]), + env_extra={"STUB_ARGV_FILE": str(argv_file), **env_extra}, + ) + + def recorded() -> List[str]: + return json.loads(argv_file.read_text(encoding="utf-8"))["argv"] + + return run, recorded + + +class TestDelegatedArgv: + def test_memory_passthrough_rewrites_the_home_flag(self) -> None: + assert delegated_argv("memory", ["pull", "--oacp-dir", "/h"]) == ["agent-memory", "pull", "--home", "/h"] + assert delegated_argv("memory", ["push", "--oacp-dir=/h"]) == ["agent-memory", "push", "--home=/h"] + + def test_memory_init_maps_to_enable(self) -> None: + assert delegated_argv("memory", ["init", "--remote", "git@x:y.git"]) == [ + "agent-memory", "enable", "--remote", "git@x:y.git", + ] + + def test_only_the_verb_position_is_mapped(self) -> None: + # A later `init` token is an argument, not the verb. + assert delegated_argv("memory", ["archive", "demo", "init"]) == ["agent-memory", "archive", "demo", "init"] + + def test_org_memory_targets_the_org_tier(self) -> None: + assert delegated_argv("org-memory", ["init", "--oacp-dir", "/h"]) == ["agent-memory", "org", "init", "--home", "/h"] + + def test_every_delegated_command_has_a_prefix(self) -> None: + assert set(DELEGATED_COMMANDS) == {"memory", "org-memory"} + + +class TestExecShim: + def test_memory_pull_execs_the_tool_with_rewritten_argv(self, stub_tool) -> None: + run, recorded = stub_tool + completed = run(["memory", "pull", "--oacp-dir", "/tmp/home"]) + assert completed.returncode == 0, completed.stderr + assert completed.stdout.strip() == "stub ran" + assert recorded()[1:] == ["pull", "--home", "/tmp/home"] + + def test_memory_init_execs_enable(self, stub_tool) -> None: + run, recorded = stub_tool + completed = run(["memory", "init", "--remote", "git@example:org/memory.git"]) + assert completed.returncode == 0, completed.stderr + assert recorded()[1:] == ["enable", "--remote", "git@example:org/memory.git"] + + def test_org_memory_init_execs_org_init(self, stub_tool) -> None: + run, recorded = stub_tool + completed = run(["org-memory", "init"]) + assert completed.returncode == 0, completed.stderr + assert recorded()[1:] == ["org", "init"] + + def test_exit_status_passes_through(self, stub_tool) -> None: + run, _recorded = stub_tool + completed = run(["memory", "push"], STUB_EXIT="3") + assert completed.returncode == 3 + + def test_absent_tool_exits_127_with_one_stderr_line(self, tmp_path: Path) -> None: + empty_bin = tmp_path / "empty-bin" + empty_bin.mkdir() + completed = _run_oacp( + ["memory", "pull"], + path=os.pathsep.join([str(empty_bin), str(Path(sys.executable).parent)]), + env_extra={}, + ) + assert completed.returncode == 127 + assert completed.stdout == "" + lines = completed.stderr.splitlines() + assert len(lines) == 1, completed.stderr + assert "agent-memory" in lines[0] + assert "pip install agent-memory-cli" in lines[0] diff --git a/tests/test_memory_sync.py b/tests/test_memory_sync.py deleted file mode 100644 index 38846c8..0000000 --- a/tests/test_memory_sync.py +++ /dev/null @@ -1,331 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Tests for the durable-memory git sync engine.""" - -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path -from typing import List, Optional, Sequence, Tuple - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -import oacp_doctor # noqa: E402 -from memory_sync import ( # noqa: E402 - CANONICAL_MEMORY_GITIGNORE, - GIT_NETWORK_TIMEOUT_SECONDS, - MemorySyncError, - clone_memory_repo, - pull_memory, - push_memory, - push_remote, -) - - -@pytest.fixture(autouse=True) -def _isolate_git_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) - monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) - - -def _git(*args: str, cwd: Optional[Path] = None) -> str: - completed = subprocess.run( - ["git", *args], - cwd=str(cwd) if cwd is not None else None, - capture_output=True, - text=True, - check=False, - ) - if completed.returncode != 0: - raise AssertionError( - f"git {' '.join(args)} failed ({completed.returncode}): " - f"{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 _configure_identity(repo: Path) -> None: - _git("config", "user.name", "OACP Test", cwd=repo) - _git("config", "user.email", "oacp-test@example.invalid", cwd=repo) - - -def _create_remote(tmp_path: Path) -> Tuple[Path, Path]: - remote = tmp_path / "remote.git" - seed = tmp_path / "seed" - _git("init", "--bare", str(remote)) - _git("init", str(seed)) - _git("checkout", "-b", "main", cwd=seed) - _configure_identity(seed) - _write(seed / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - _write(seed / ".oacp-memory-repo", "memory sync enabled\n") - _write(seed / "org-memory" / "recent.md", "seed\n") - _git("add", ".gitignore", ".oacp-memory-repo", "org-memory/recent.md", cwd=seed) - _git("commit", "-m", "seed memory", cwd=seed) - _git("remote", "add", "origin", str(remote), cwd=seed) - _git("push", "-u", "origin", "main", cwd=seed) - _git("--git-dir", str(remote), "symbolic-ref", "HEAD", "refs/heads/main") - return remote, seed - - -def _clone(remote: Path, destination: Path) -> None: - clone_memory_repo(destination, str(remote)) - _configure_identity(destination) - - -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", "-m", f"update {relative_path}", cwd=repo) - _git("push", cwd=repo) - - -class RecordingRunner: - def __init__(self) -> None: - self.calls: List[Tuple[Tuple[str, ...], Optional[int]]] = [] - - def __call__( - self, - command: Sequence[str], - *, - timeout: Optional[int] = None, - ) -> Tuple[int, str]: - call = tuple(command) - self.calls.append((call, timeout)) - args = list(command[1:]) - if args == ["rev-parse", "--is-inside-work-tree"]: - return 0, "true" - if args == ["status", "--porcelain"]: - return 0, "" - if args == ["remote"]: - return 0, "origin" - if args == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]: - return 0, "origin/main" - if args == ["rev-list", "--left-right", "--count", "HEAD...origin/main"]: - return 0, "0 1" - return 0, "" - - -def _assert_timed_call( - runner: RecordingRunner, - expected_args: Sequence[str], -) -> None: - assert any( - list(command[1:]) == list(expected_args) - and timeout == GIT_NETWORK_TIMEOUT_SECONDS - for command, timeout in runner.calls - ) - - -def test_fetch_pull_push_and_clone_receive_network_timeouts(tmp_path: Path) -> None: - root = tmp_path / "oacp" - root.mkdir() - _write(root / ".oacp-memory-repo", "memory sync enabled\n") - runner = RecordingRunner() - - lines = pull_memory(root, runner=runner) - push_remote(root, runner=runner) - clone_target = tmp_path / "clone" - clone_memory_repo(clone_target, "example.invalid/repo.git", runner=runner) - - assert lines == ["OACP memory pull: synced 1 commit(s)."] - _assert_timed_call(runner, ["fetch", "--quiet"]) - _assert_timed_call(runner, ["pull", "--ff-only"]) - _assert_timed_call(runner, ["push"]) - _assert_timed_call( - runner, - ["clone", "example.invalid/repo.git", str(clone_target)], - ) - - -def test_doctor_memory_fetch_forwards_timeout( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "oacp" - root.mkdir() - _write(root / ".oacp-memory-repo", "memory sync enabled\n") - _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - calls: List[Tuple[Tuple[str, ...], Optional[int]]] = [] - - def fake_run_git_command( - command: Sequence[str], - *, - cwd: Path, - timeout: Optional[int] = None, - ) -> Tuple[int, str]: - assert cwd == root - call = tuple(command) - calls.append((call, timeout)) - args = list(command[1:]) - if args == ["rev-parse", "--is-inside-work-tree"]: - return 0, "true" - if args == ["ls-files"]: - return 0, ".gitignore\n.oacp-memory-repo" - if args == ["ls-files", "--others", "--exclude-standard"]: - return 0, "" - if args == ["status", "--porcelain"]: - return 0, "" - if args == ["remote"]: - return 0, "origin" - if args == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]: - return 1, "" - if args == ["rev-parse", "--verify", "HEAD"]: - return 1, "" - return 0, "" - - monkeypatch.setattr(oacp_doctor, "run_git_command", fake_run_git_command) - - oacp_doctor.check_memory_sync(root) - - assert any( - list(command[1:]) == ["fetch", "--quiet"] - and timeout == GIT_NETWORK_TIMEOUT_SECONDS - for command, timeout in calls - ) - - -def test_force_clone_preserves_existing_home_as_backup(tmp_path: Path) -> None: - remote, _ = _create_remote(tmp_path) - root = tmp_path / "oacp" - _write(root / "local-only.txt", "keep me\n") - - lines = clone_memory_repo(root, str(remote), force=True) - - backups = list(tmp_path.glob("oacp.backup-*")) - assert len(backups) == 1 - assert (backups[0] / "local-only.txt").read_text(encoding="utf-8") == "keep me\n" - assert (root / "org-memory" / "recent.md").read_text(encoding="utf-8") == "seed\n" - assert any("Moved existing OACP_HOME aside" in line for line in lines) - - -def test_force_clone_failure_restores_existing_home(tmp_path: Path) -> None: - root = tmp_path / "oacp" - _write(root / "local-only.txt", "keep me\n") - - with pytest.raises(MemorySyncError, match="git clone failed"): - clone_memory_repo(root, str(tmp_path / "missing.git"), force=True) - - assert (root / "local-only.txt").read_text(encoding="utf-8") == "keep me\n" - assert not list(tmp_path.glob("oacp.backup-*")) - - -def test_pull_memory_fast_forwards_real_repo(tmp_path: Path) -> None: - remote, seed = _create_remote(tmp_path) - root = tmp_path / "oacp" - _clone(remote, root) - _commit_and_push(seed, "org-memory/new.md", "remote update\n") - - lines = pull_memory(root) - - assert lines == ["OACP memory pull: synced 1 commit(s)."] - assert (root / "org-memory" / "new.md").read_text(encoding="utf-8") == "remote update\n" - - -def test_push_memory_commits_dirty_memory_and_pushes(tmp_path: Path) -> None: - remote, _ = _create_remote(tmp_path) - root = tmp_path / "oacp" - _clone(remote, root) - _write(root / "org-memory" / "local.md", "local update\n") - - lines, code = push_memory(root) - - assert code == 0 - assert any("committed 1 file(s)" in line for line in lines) - assert _git("status", "--porcelain", cwd=root) == "" - assert ( - _git("--git-dir", str(remote), "show", "main:org-memory/local.md") - == "local update" - ) - - -def test_push_memory_refuses_behind_repo(tmp_path: Path) -> None: - remote, seed = _create_remote(tmp_path) - root = tmp_path / "oacp" - _clone(remote, root) - _commit_and_push(seed, "org-memory/remote.md", "remote update\n") - - lines, code = push_memory(root) - - assert code == 1 - assert any("behind upstream" in line for line in lines) - - -def test_push_memory_refuses_diverged_repo(tmp_path: Path) -> None: - remote, seed = _create_remote(tmp_path) - root = tmp_path / "oacp" - _clone(remote, root) - _write(root / "org-memory" / "local.md", "local update\n") - _git("add", "org-memory/local.md", cwd=root) - _git("commit", "-m", "local update", cwd=root) - _commit_and_push(seed, "org-memory/remote.md", "remote update\n") - - lines, code = push_memory(root) - - assert code == 1 - assert any("diverged" in line for line in lines) - - -def test_keystore_paths_are_never_allowed_memory_paths() -> None: - # Regression guard for the keystore sync guard: - # nothing under keys/ may ever be pushable, structurally. - from memory_sync import is_allowed_memory_path - - denied = [ - "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", - ] - for path in denied: - assert not is_allowed_memory_path(path), path - # No false positive: memory files that merely mention keys stay allowed. - assert is_allowed_memory_path("projects/demo/memory/keys.md") - - -def test_canonical_gitignore_denies_keystore_last() -> None: - lines = CANONICAL_MEMORY_GITIGNORE.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( - i for i, line in enumerate(lines) if line.startswith("!") - ) - - -def test_debrief_paths_are_allowed_memory_paths() -> None: - # The central debrief store must sync cross-machine: every path under - # org-memory/debriefs/** is inside the memory allowlist. - from memory_sync import is_allowed_memory_path - - allowed = [ - "org-memory/debriefs/.gitkeep", - "org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md", - ] - for path in allowed: - assert is_allowed_memory_path(path), path - - -def test_canonical_gitignore_syncs_nested_debrief_files(tmp_path: Path) -> None: - # The canonical allowlist must actually stage deeply nested debrief - # files (project/year/month), not just top-level org-memory content. - repo = tmp_path / "oacp" - _git("init", str(repo)) - _configure_identity(repo) - _write(repo / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - debrief = "org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md" - _write(repo / debrief, "---\nschema_version: 1\n---\nbody\n") - _write(repo / "org-memory" / "debriefs" / ".gitkeep", "") - _git("add", "-A", cwd=repo) - tracked = _git("ls-files", cwd=repo).splitlines() - assert debrief in tracked - assert "org-memory/debriefs/.gitkeep" in tracked diff --git a/tests/test_oacp_cli.py b/tests/test_oacp_cli.py index f70ed38..75b32f4 100644 --- a/tests/test_oacp_cli.py +++ b/tests/test_oacp_cli.py @@ -48,16 +48,43 @@ def test_dispatches_add_agent(self, run_script) -> None: "add_agent.py", ["demo", "alice", "--runtime", "claude"] ) + @mock.patch("oacp.cli.os.execvp") + @mock.patch("oacp.cli.shutil.which", return_value="/opt/bin/agent-memory") @mock.patch("oacp.cli._run_script", return_value=0) - def test_dispatches_memory_namespace(self, run_script) -> None: - code, stdout, stderr = self._run(["memory", "archive", "demo", "notes.md"]) + def test_memory_namespace_delegates_to_the_memory_tool(self, run_script, which, execvp) -> None: + code, stdout, stderr = self._run(["memory", "archive", "demo", "notes.md", "--oacp-dir", "/tmp/home"]) self.assertEqual(code, 0) self.assertEqual(stdout, "") self.assertEqual(stderr, "") - run_script.assert_called_once_with( - "memory_cli.py", ["archive", "demo", "notes.md"] + run_script.assert_not_called() + which.assert_called_once_with("agent-memory") + execvp.assert_called_once_with( + "/opt/bin/agent-memory", + ["agent-memory", "archive", "demo", "notes.md", "--home", "/tmp/home"], ) + @mock.patch("oacp.cli.os.execvp") + @mock.patch("oacp.cli.shutil.which", return_value="/opt/bin/agent-memory") + @mock.patch("oacp.cli._run_script", return_value=0) + def test_org_memory_init_delegates_to_the_org_tier_verb(self, run_script, which, execvp) -> None: + code, _stdout, _stderr = self._run(["org-memory", "init"]) + self.assertEqual(code, 0) + run_script.assert_not_called() + execvp.assert_called_once_with("/opt/bin/agent-memory", ["agent-memory", "org", "init"]) + + @mock.patch("oacp.cli.os.execvp") + @mock.patch("oacp.cli.shutil.which", return_value=None) + @mock.patch("oacp.cli._run_script", return_value=0) + def test_memory_namespace_without_the_tool_exits_127(self, run_script, which, execvp) -> None: + code, stdout, stderr = self._run(["memory", "pull"]) + self.assertEqual(code, 127) + self.assertEqual(stdout, "") + self.assertEqual(stderr.count("\n"), 1) + self.assertIn("agent-memory", stderr) + self.assertIn("pip install agent-memory-cli", stderr) + run_script.assert_not_called() + execvp.assert_not_called() + @mock.patch("oacp.cli._run_script", return_value=0) def test_dispatches_session_init(self, run_script) -> None: code, stdout, stderr = self._run( diff --git a/tests/test_oacp_doctor.py b/tests/test_oacp_doctor.py index f6e8973..c3ba7a3 100644 --- a/tests/test_oacp_doctor.py +++ b/tests/test_oacp_doctor.py @@ -8,7 +8,6 @@ import datetime as dt import json import os -import subprocess import sys import tempfile import unittest @@ -29,7 +28,6 @@ check_agent_status, check_environment, check_inbox_health, - check_memory_sync, check_schemas, check_trust, check_workspace, @@ -40,7 +38,6 @@ validate_autonomy_config_data, ) from autonomy_gate import evaluate_autonomy # noqa: E402 -from memory_sync import CANONICAL_MEMORY_GITIGNORE # noqa: E402 def _write(path: Path, content: str) -> None: @@ -74,8 +71,8 @@ def test_all_tools_available(self) -> None: which = _fake_which({"git", "python3", "gh", "ruff", "shellcheck"}) cat = check_environment(runner=runner, which_fn=which) self.assertEqual(cat.name, "Environment") - # 2 required + 3 optional + pyyaml = 6 results - self.assertEqual(len(cat.results), 6) + # 2 required + 3 optional + agent-memory + pyyaml = 7 results + self.assertEqual(len(cat.results), 7) for r in cat.results: if r.name in ("git", "python3", "gh"): self.assertEqual(r.severity, Severity.ok, f"{r.name} should be ok") @@ -102,6 +99,24 @@ def test_missing_optional_tool(self) -> None: self.assertEqual(sc_result.severity, Severity.skip) + def test_memory_tool_row_reports_version_and_its_own_doctor(self) -> None: + runner = _fake_runner({"agent-memory": (0, "agent-memory 0.1.0")}) + which = _fake_which({"git", "python3", "agent-memory"}) + cat = check_environment(runner=runner, which_fn=which) + row = next(r for r in cat.results if r.name == "agent-memory") + self.assertEqual(row.severity, Severity.ok) + self.assertEqual(row.message, "agent-memory — agent-memory 0.1.0 (run agent-memory doctor)") + + def test_memory_tool_row_when_not_installed(self) -> None: + runner = _fake_runner() + which = _fake_which({"git", "python3"}) + cat = check_environment(runner=runner, which_fn=which) + row = next(r for r in cat.results if r.name == "agent-memory") + self.assertEqual(row.severity, Severity.skip) + self.assertEqual(row.message, "agent-memory — not installed") + self.assertEqual(row.fix_hint, "Install: pip install agent-memory-cli") + + class TestCheckWorkspace(unittest.TestCase): def test_valid_workspace(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -811,23 +826,6 @@ def test_hidden_agent_scaffolding_is_ignored(self) -> None: ) self.assertNotIn(".claude", diagnostics) - def test_include_memory_adds_memory_sync_category(self) -> None: - with tempfile.TemporaryDirectory() as td: - hub_dir = Path(td) - runner = _fake_runner() - which = _fake_which({"git", "python3", "gh"}) - - cats = run_doctor( - oacp_dir=hub_dir, - project=None, - include_memory=True, - runner=runner, - which_fn=which, - ) - - self.assertEqual(cats[-1].name, "Memory Sync") - self.assertEqual(cats[-1].results[0].severity, Severity.skip) - class TestCheckTrust(unittest.TestCase): KID = "a" * 43 @@ -1105,124 +1103,6 @@ def test_integrity_failure_is_error(self) -> None: self.assertIn("0 gap(s) across 0 receiver(s)", completeness.message) -class TestCheckMemorySync(unittest.TestCase): - def _git(self, cwd: Path, *args: str) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", *args], - cwd=str(cwd), - capture_output=True, - text=True, - check=False, - ) - - def test_memory_sync_not_configured(self) -> None: - with tempfile.TemporaryDirectory() as td: - cat = check_memory_sync(Path(td)) - - self.assertEqual(cat.name, "Memory Sync") - self.assertEqual(len(cat.results), 1) - self.assertEqual(cat.results[0].severity, Severity.skip) - self.assertIn("not configured", cat.results[0].message) - - def test_memory_sync_warns_for_tracked_agent_state(self) -> None: - with tempfile.TemporaryDirectory() as td: - root = Path(td) - self.assertEqual(self._git(root, "init").returncode, 0) - _write(root / ".oacp-memory-repo", "marker\n") - _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - _write(root / "projects" / "demo" / "agents" / "codex" / "status.yaml", "busy\n") - self.assertEqual( - self._git( - root, - "add", - "-f", - ".oacp-memory-repo", - ".gitignore", - "projects/demo/agents/codex/status.yaml", - ).returncode, - 0, - ) - - cat = check_memory_sync(root) - messages = "\n".join(result.message for result in cat.results) - - self.assertIn("tracked file(s) outside memory allowlist", messages) - self.assertIn("agents/ file(s) tracked", messages) - - def test_memory_sync_warns_for_escaping_overlay(self) -> None: - with tempfile.TemporaryDirectory() as td: - root = Path(td) - self.assertEqual(self._git(root, "init").returncode, 0) - _write(root / ".oacp-memory-repo", "marker\n") - _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - _write(root / "projects" / "demo" / "memory" / ".gitignore", "!../agents/**\n") - - cat = check_memory_sync(root) - overlay = next(result for result in cat.results if result.name == "memory-overlays") - - self.assertEqual(overlay.severity, Severity.warn) - self.assertIn("escape memory", overlay.message) - - def test_memory_sync_does_not_report_agents_clean_when_ls_files_fails(self) -> None: - with tempfile.TemporaryDirectory() as td: - root = Path(td) - _write(root / ".oacp-memory-repo", "marker\n") - _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - - def runner(command: Sequence[str]) -> Tuple[int, str]: - if command[:2] == ["git", "rev-parse"]: - if "--verify" in command: - return 1, "" - return 0, "true" - if command[:2] == ["git", "status"]: - return 0, "" - if command[:2] == ["git", "remote"]: - return 0, "" - if command[:2] == ["git", "ls-files"] and "--others" not in command: - return 1, "boom" - if command[:2] == ["git", "ls-files"] and "--others" in command: - return 0, "" - return 0, "" - - cat = check_memory_sync(root, runner=runner) - results = {result.name: result for result in cat.results} - - self.assertEqual(results["tracked-allowlist"].severity, Severity.warn) - self.assertNotIn("agents-tracked", results) - - def test_memory_sync_adapts_legacy_runner_for_network_timeout(self) -> None: - with tempfile.TemporaryDirectory() as td: - root = Path(td) - _write(root / ".oacp-memory-repo", "marker\n") - _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) - calls = [] - - def runner(command: Sequence[str]) -> Tuple[int, str]: - calls.append(list(command)) - args = list(command[1:]) - if args == ["rev-parse", "--is-inside-work-tree"]: - return 0, "true" - if args == ["status", "--porcelain"]: - return 0, "" - if args == ["remote"]: - return 0, "origin" - if args == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]: - return 0, "origin/main" - if args == ["fetch", "--quiet"]: - return 0, "" - if args == ["rev-list", "--left-right", "--count", "HEAD...origin/main"]: - return 0, "0 0" - if args == ["rev-parse", "--verify", "HEAD"]: - return 1, "" - if args[:1] == ["ls-files"]: - return 0, "" - return 0, "" - - check_memory_sync(root, runner=runner) - - self.assertIn(["git", "fetch", "--quiet"], calls) - - class TestApplyFixes(unittest.TestCase): """Tests for the --fix path.""" diff --git a/tests/test_org_memory_doctor.py b/tests/test_org_memory_doctor.py deleted file mode 100644 index fcb3f8b..0000000 --- a/tests/test_org_memory_doctor.py +++ /dev/null @@ -1,195 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Doctor setup checks for the org-memory debrief store. - -The doctor's contract is setup-level only: directory presence, canonical -path layout, staging leftovers, and irregular entries. It never opens -debrief files — content and format verification belong to the writer -contract and git history. -""" - -from __future__ import annotations - -import os -import shutil -import sys -from pathlib import Path - -import pytest -import yaml - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -from oacp_doctor import Severity, check_org_memory # noqa: E402 - -CASES_DIR = Path(__file__).resolve().parent / "conformance" / "org_memory" / "cases" -CASE_NAMES = sorted( - p.name for p in CASES_DIR.iterdir() if p.is_dir() and not p.name.startswith(".") -) - - -@pytest.mark.parametrize("case_name", CASE_NAMES) -def test_conformance_case(case_name: str, tmp_path: Path) -> None: - case = CASES_DIR / case_name - expected = yaml.safe_load((case / "expected.yaml").read_text(encoding="utf-8")) - root = tmp_path / "oacp" - 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}" - - -# ── Unit checks ────────────────────────────────────────────────────────── - - -def _write_debrief(root: Path, name: str = "20260825-alice-1f3a9c2b.md") -> Path: - path = root / "org-memory" / "debriefs" / "demo-project" / "2026" / "08" / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("---\nschema_version: 1\n---\nbody\n", encoding="utf-8") - return path - - -def _rows(root: Path) -> list: - return [(r.name, r.severity) for r in check_org_memory(root).results] - - -def test_canonical_layout_passes(tmp_path: Path) -> None: - root = tmp_path / "oacp" - root.mkdir() - _write_debrief(root) - - assert ("debriefs-layout", Severity.ok) in _rows(root) - - -@pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as 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 — the doctor must not open debrief files at all. - root = tmp_path / "oacp" - root.mkdir() - record = _write_debrief(root) - os.chmod(record, 0o000) - try: - rows = _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 / "oacp" - 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 _rows(root) - - -def test_symlinked_record_flagged(tmp_path: Path) -> None: - root = tmp_path / "oacp" - root.mkdir() - real = _write_debrief(root) - (real.parent / "20260825-alice-99zz00aa.md").symlink_to(real) - - rows = _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 / "oacp" - root.mkdir() - _write_debrief(root) - outside = tmp_path / "outside" - outside.mkdir() - (root / "org-memory" / "debriefs" / "linked-project").symlink_to( - outside, target_is_directory=True - ) - - assert ("debriefs-irregular", Severity.error) in _rows(root) - - -@pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") -def test_unreadable_directory_is_not_a_clean_empty_store(tmp_path: Path) -> None: - root = tmp_path / "oacp" - 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) - rows = [(r.name, r.severity) for r in cat.results] - assert ("debriefs-unreadable", Severity.error) in rows - assert not any( - r.name == "debriefs-layout" and "empty store" in r.message - for r in cat.results - ) - - -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 / "oacp" - root.mkdir() - _write_debrief(root) - real_is_symlink = Path.is_symlink - - def flaky(self): - 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) - rows = [(r.name, r.severity) for r in cat.results] - assert ("debriefs-unreadable", Severity.error) in rows - 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 / "oacp" - root.mkdir() - _write_debrief(root) - victim = _write_debrief(root, "20260825-bob-77aa88bb.md") - real_stat = Path.stat - - def flaky(self, *args, **kwargs): - if self.name == victim.name: - raise PermissionError(13, "Permission denied", str(self)) - return real_stat(self, *args, **kwargs) - - monkeypatch.setattr(Path, "stat", flaky) - rows = _rows(root) - assert ("debriefs-unreadable", Severity.error) in rows - assert ("debriefs-layout", Severity.ok) in rows diff --git a/tests/test_package_content.py b/tests/test_package_content.py index 38a8934..a1a1af0 100644 --- a/tests/test_package_content.py +++ b/tests/test_package_content.py @@ -12,11 +12,12 @@ import posixpath import re +import shutil import subprocess import sys import zipfile from pathlib import Path -from typing import Set +from typing import Iterable, List, Set import pytest @@ -41,12 +42,35 @@ "oacp/_scripts/create_handoff_packet.py", "oacp/_scripts/init_project_workspace.sh", "oacp/_scripts/session_lifecycle_hooks.py", + "oacp/_scripts/init_org_memory.py", + "oacp/_scripts/promote_to_archive.py", + "oacp/_scripts/restore_from_archive.py", +] +# The memory engine ships as `agent-memory-cli`; the kernel wheel carries no +# memory module and no org-memory templates. Matched by shape, not by name, so +# a re-added module fails here before it is ever listed above. The script +# shape covers a flat module and a package with everything under it, in +# either spelling the preflight memory-boundary guard rejects. +MEMORY_ENGINE_SHAPES = [ + re.compile(r"^oacp/_scripts/(?:memory_|agent_memory)[^/]*(?:/|$)"), + re.compile(r"^oacp/_templates/org-memory/"), +] +# Force-included into a copy of this tree to prove the shapes against a real +# build: (source path, wheel destination). +PLANTED_ENGINE_FILES = [ + ("scripts/memory_probe/__init__.py", "oacp/_scripts/memory_probe/__init__.py"), + ("scripts/memory_probe_flat.py", "oacp/_scripts/memory_probe_flat.py"), + ("templates/org-memory/probe.md", "oacp/_templates/org-memory/probe.md"), ] -@pytest.fixture(scope="module") -def wheel_names(tmp_path_factory) -> Set[str]: - outdir = tmp_path_factory.mktemp("wheel") +def memory_engine_files(names: Iterable[str]) -> List[str]: + return sorted( + name for name in names if any(shape.match(name) for shape in MEMORY_ENGINE_SHAPES) + ) + + +def _build_wheel_names(tree: Path, outdir: Path) -> Set[str]: completed = subprocess.run( [ sys.executable, @@ -56,7 +80,7 @@ def wheel_names(tmp_path_factory) -> Set[str]: "--no-isolation", "--outdir", str(outdir), - str(REPO_ROOT), + str(tree), ], capture_output=True, text=True, @@ -73,6 +97,44 @@ def wheel_names(tmp_path_factory) -> Set[str]: return set(zf.namelist()) +@pytest.fixture(scope="module") +def wheel_names(tmp_path_factory) -> Set[str]: + return _build_wheel_names(REPO_ROOT, tmp_path_factory.mktemp("wheel")) + + +@pytest.fixture(scope="module") +def planted_wheel_names(tmp_path_factory) -> Set[str]: + """Wheel built from a copy of this tree with engine-shaped files force-included.""" + tracked = subprocess.run( + ["git", "-C", str(REPO_ROOT), "ls-files", "-z"], + capture_output=True, + check=False, + ) + if tracked.returncode != 0: + pytest.skip("planted build needs a git checkout to copy") + tree = tmp_path_factory.mktemp("planted-tree") + for raw in tracked.stdout.split(b"\0"): + rel = raw.decode() + source = REPO_ROOT / rel + if not rel or not source.is_file(): + continue + target = tree / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + rows = [] + for source_rel, destination in PLANTED_ENGINE_FILES: + planted = tree / source_rel + planted.parent.mkdir(parents=True, exist_ok=True) + planted.write_text("# planted by test_package_content\n") + rows.append(f'"{source_rel}" = "{destination}"\n') + pyproject = tree / "pyproject.toml" + header = "[tool.hatch.build.targets.wheel.force-include]\n" + text = pyproject.read_text() + assert text.count(header) == 1 + pyproject.write_text(text.replace(header, header + "".join(rows), 1)) + return _build_wheel_names(tree, tmp_path_factory.mktemp("planted-wheel")) + + class TestKernelDocsShipped: def test_kernel_docs_in_wheel(self, wheel_names): missing = [doc for doc in KERNEL_DOCS if doc not in wheel_names] @@ -125,6 +187,39 @@ def test_removed_scripts_not_shipped(self, wheel_names): present = [name for name in REMOVED_SCRIPTS if name in wheel_names] assert not present, f"removed scripts still in wheel: {present}" + def test_memory_engine_not_shipped(self, wheel_names): + present = memory_engine_files(wheel_names) + assert not present, f"memory engine files in wheel: {present}" + + def test_memory_engine_shapes_trip_on_planted_build(self, planted_wheel_names): + # The same tree with a memory_* package, a flat memory_* module and an + # org-memory template force-included: every planted file is caught and + # nothing the real build ships is. + planted = {destination for _, destination in PLANTED_ENGINE_FILES} + assert planted <= planted_wheel_names + assert set(memory_engine_files(planted_wheel_names)) == planted + + +class TestMemoryEngineShapes: + def test_shapes_cover_packages_modules_and_templates(self): + caught = [ + "oacp/_scripts/memory_sync.py", + "oacp/_scripts/memory_probe/__init__.py", + "oacp/_scripts/memory_probe/nested/deep.py", + "oacp/_scripts/agent_memory.py", + "oacp/_scripts/agent_memory/__init__.py", + "oacp/_templates/org-memory/README.md", + "oacp/_templates/org-memory/events/.gitkeep", + ] + kept = [ + "oacp/_scripts/oacp_doctor.py", + "oacp/_scripts/codex_session_init.py", + "oacp/_protocol/org_memory.md", + "oacp/guides/memory-context.md", + "oacp/_templates/inbox_message.template.yaml", + ] + assert memory_engine_files(caught + kept) == sorted(caught) + # Retained shipped tools and templates must not direct users to the retired # entry points. Userland protocol docs are deliberately excluded — their diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 783f98c..4ed8ad1 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -16,6 +16,7 @@ from preflight import ( # noqa: E402 check_conflict_markers, + check_memory_boundary, check_packaging_boundary, check_yaml_syntax, parse_force_include, @@ -238,6 +239,85 @@ def test_parse_stops_at_next_table(self) -> None: self.assertEqual(entries, [("scripts/a.py", "oacp/_scripts/a.py")]) +class TestMemoryBoundary(unittest.TestCase): + """No kernel module imports the memory engine, in either spelling or shape.""" + + def test_clean_kernel_passes(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "oacp" / "cli.py", "import shutil\nMEMORY_TOOL = 'agent-memory'\n") + _write(repo / "scripts" / "a.py", "from _oacp_env import resolve_oacp_home\n") + _write( + repo / "scripts" / "b.py", + '"""Docstring: from memory_sync import pull_memory."""\n' + "# import memory_sync in a comment only\n" + "HINT = 'import agent_memory'\n" + "PATH = ['agent_memory', 'memory_sync']\n" + "import importlib, sys\n" + "importlib.import_module(name=sys.argv[1])\n" + "importlib.import_module(sys.argv[1], package=None)\n" + "__import__(sys.argv[1], fromlist=[sys.argv[2]])\n", + ) + result = check_memory_boundary(repo) + self.assertTrue(result.passed, result.details) + self.assertIn("3 kernel modules", result.details) + + def test_each_import_shape_fails(self) -> None: + # (planted source appended after `import json`, line the guard reports) + planted = [ + ("from memory_sync import pull_memory\n", 2), + ("import memory_cli\n", 2), + ("from agent_memory import sync\n", 2), + ("from agent_memory.sync import pull\n", 2), + ("import agent_memory\n", 2), + ("import os, memory_sync\n", 2), + ("import os as _os, memory_sync as ms\n", 2), + ("import os; import agent_memory\n", 2), + ("from . import memory_sync\n", 2), + ("from .. import pull_memory, memory_cli\n", 2), + ("from oacp._scripts.memory_sync import pull_memory\n", 2), + ("from oacp._scripts import memory_sync\n", 2), + ("def f():\n from memory_sync import MemorySyncError, pull_memory\n", 3), + ("import importlib\nimportlib.import_module('memory_sync')\n", 3), + ("__import__('agent_memory')\n", 2), + ("import importlib\nimportlib.import_module(name='memory_sync')\n", 3), + ("__import__(name='agent_memory')\n", 2), + ("from importlib import import_module\nimport_module(name='memory_cli')\n", 3), + ("import importlib\nimportlib.import_module('.sync', package='agent_memory')\n", 3), + ("import importlib\nimportlib.import_module('.sync', 'memory_cli')\n", 3), + ("__import__('oacp._scripts', fromlist=['memory_sync'])\n", 2), + ("__import__('oacp._scripts', None, None, ('json', 'agent_memory'))\n", 2), + ] + for statement, lineno in planted: + with self.subTest(statement=statement.strip()): + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "clean.py", "import json\n") + _write(repo / "scripts" / "planted.py", "import json\n" + statement) + result = check_memory_boundary(repo) + self.assertFalse(result.passed) + self.assertIn(f"scripts/planted.py:{lineno}: ", result.details) + self.assertNotIn("clean.py", result.details) + + def test_unparseable_module_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "import json\n") + _write(repo / "scripts" / "broken.py", "import (\n") + result = check_memory_boundary(repo) + self.assertFalse(result.passed) + self.assertIn("scripts/broken.py:1: unparseable", result.details) + + def test_only_kernel_dirs_are_scanned(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "import json\n") + _write(repo / "tests" / "test_x.py", "from memory_sync import x\n") + _write(repo / "scripts" / "__pycache__" / "junk.py", "import memory_sync\n") + result = check_memory_boundary(repo) + self.assertTrue(result.passed, result.details) + + class TestRunPreflight(unittest.TestCase): def test_full_mode_runs_make_test(self) -> None: with tempfile.TemporaryDirectory() as td: diff --git a/tests/test_readme_commands.py b/tests/test_readme_commands.py index 3a7ea5e..0fabb26 100644 --- a/tests/test_readme_commands.py +++ b/tests/test_readme_commands.py @@ -31,7 +31,7 @@ parse_commands, render_block, ) -from oacp.cli import SCRIPT_NAMES # noqa: E402 +from oacp.cli import DELEGATED_COMMANDS, SCRIPT_NAMES # noqa: E402 def _readme_text() -> str: @@ -62,7 +62,7 @@ def test_help_text_matches_executed_cli_help() -> None: def test_help_text_lists_exactly_the_dispatchable_commands() -> None: names = [name for name, _ in parse_commands(load_help_text())] assert len(names) == len(set(names)), f"duplicate command in HELP_TEXT: {names}" - assert sorted(names) == sorted(SCRIPT_NAMES) + assert sorted(names) == sorted([*SCRIPT_NAMES, *DELEGATED_COMMANDS]) def test_parse_commands_rejects_help_text_without_commands_block() -> None: diff --git a/tests/test_setup_runtime.py b/tests/test_setup_runtime.py index 0a8c027..5135411 100644 --- a/tests/test_setup_runtime.py +++ b/tests/test_setup_runtime.py @@ -11,6 +11,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock import yaml @@ -20,6 +21,7 @@ CLAUDE_SUPPORTED_MESSAGE_TYPES, CODEX_SUPPORTED_MESSAGE_TYPES, ) +import setup_runtime as setup_runtime_module # noqa: E402 from setup_runtime import setup_runtime # noqa: E402 @@ -32,7 +34,6 @@ def test_claude_creates_agent_file_and_skills_dir(self) -> None: ) agent_file = repo_dir / ".claude" / "agents" / "myproj.md" - pull_hook = repo_dir / ".claude" / "hooks" / "oacp-memory-pull.sh" settings_file = repo_dir / ".claude" / "settings.json" self.assertTrue(agent_file.is_file()) agent_content = agent_file.read_text(encoding="utf-8") @@ -43,16 +44,11 @@ def test_claude_creates_agent_file_and_skills_dir(self) -> None: self.assertEqual(positions, sorted(positions)) self.assertIn("org memory on demand", agent_content) self.assertTrue((repo_dir / ".claude" / "skills").is_dir()) - self.assertTrue(pull_hook.is_file()) - self.assertFalse( - (repo_dir / ".claude" / "hooks" / "oacp-memory-push.sh").exists() - ) + # Memory hooks belong to the memory tool: none are written here. + self.assertFalse((repo_dir / ".claude" / "hooks").exists()) self.assertTrue(settings_file.is_file()) - pull_content = pull_hook.read_text(encoding="utf-8") - self.assertIn("Claude hook event: SessionStart", pull_content) - self.assertIn("oacp memory pull", pull_content) settings = json.loads(settings_file.read_text(encoding="utf-8")) - self.assertIn("SessionStart", settings["hooks"]) + self.assertNotIn("SessionStart", settings["hooks"]) self.assertNotIn("SessionEnd", settings["hooks"]) self.assertIn("PreToolUse", settings["hooks"]) envelope_entry = settings["hooks"]["PreToolUse"][0] @@ -62,8 +58,9 @@ def test_claude_creates_agent_file_and_skills_dir(self) -> None: ) self.assertIn(".claude/agents/myproj.md", result["created_files"]) self.assertIn(".claude/skills/", result["created_files"]) - self.assertIn(".claude/hooks/oacp-memory-pull.sh", result["created_files"]) + self.assertNotIn(".claude/hooks/oacp-memory-pull.sh", result["created_files"]) self.assertIn(".claude/settings.json", result["created_files"]) + self.assertEqual(result["retired_files"], []) def test_claude_envelope_hook_registration_is_idempotent(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -99,7 +96,9 @@ def test_codex_creates_agents_md(self) -> None: content = agents_md.read_text(encoding="utf-8") self.assertIn("OACP", content) self.assertIn("oacp send", content) - self.assertIn("oacp session-init --pull-memory", content) + self.assertIn("oacp session-init --project", content) + self.assertNotIn("--pull-memory", content) + self.assertIn("agent-memory setup codex", content) self.assertIn("project memory files", content) self.assertIn("Org memory is retrieved on demand", content) hooks = json.loads(hooks_file.read_text(encoding="utf-8")) @@ -108,8 +107,8 @@ def test_codex_creates_agents_md(self) -> None: self.assertEqual(len(entry["hooks"]), 1) handler = entry["hooks"][0] self.assertEqual(handler["type"], "command") - self.assertIn("oacp session-init --hook --pull-memory", handler["command"]) - self.assertIn("--project demo", handler["command"]) + self.assertIn("oacp session-init --hook --project demo", handler["command"]) + self.assertNotIn("--pull-memory", handler["command"]) self.assertIn(f"--hub-dir {oacp_root}", handler["command"]) self.assertEqual(handler["additionalContextLimit"], 2500) self.assertEqual(handler["timeout"], 60) @@ -466,10 +465,92 @@ def test_claude_settings_merge_preserves_existing_values(self) -> None: self.assertEqual(data["env"]["EXISTING"], "1") self.assertIn("Stop", data["hooks"]) - self.assertIn("SessionStart", data["hooks"]) + self.assertNotIn("SessionStart", data["hooks"]) self.assertNotIn("SessionEnd", data["hooks"]) + self.assertIn("PreToolUse", data["hooks"]) self.assertIn(".claude/settings.json", result["created_files"]) + def test_claude_retires_generated_memory_hooks_by_command_and_digest(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + settings = repo_dir / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/oacp-memory-pull.sh", + "timeout": 30, + }, + { + "type": "command", + "command": ".claude/hooks/custom-startup.sh", + }, + ], + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/oacp-memory-push.sh", + } + ] + } + ], + } + } + ), + encoding="utf-8", + ) + hooks_dir = repo_dir / ".claude" / "hooks" + hooks_dir.mkdir() + generated_pull = hooks_dir / "oacp-memory-pull.sh" + generated_pull.write_text(setup_runtime_module.CLAUDE_LEGACY_MEMORY_PULL_HOOK, encoding="utf-8") + edited_push = hooks_dir / "oacp-memory-push.sh" + edited_push.write_text("#!/usr/bin/env bash\necho mine\n", encoding="utf-8") + + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + + data = json.loads(settings.read_text(encoding="utf-8")) + start_commands = [ + hook["command"] + for entry in data["hooks"]["SessionStart"] + for hook in entry.get("hooks", []) + ] + self.assertEqual(start_commands, [".claude/hooks/custom-startup.sh"]) + self.assertNotIn("SessionEnd", data["hooks"]) + # The generated script goes; the edited one stays and is reported. + self.assertFalse(generated_pull.exists()) + self.assertEqual(edited_push.read_text(encoding="utf-8"), "#!/usr/bin/env bash\necho mine\n") + self.assertEqual(result["retired_files"], [".claude/hooks/oacp-memory-pull.sh"]) + self.assertIn(".claude/hooks/oacp-memory-push.sh", result["skipped_files"]) + + def test_claude_setup_is_idempotent_after_the_migration_pass(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + self.assertEqual(result["retired_files"], []) + self.assertIn(".claude/settings.json", result["skipped_files"]) + + def test_main_points_at_the_memory_tool_setup(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + (repo_dir / ".git").mkdir() + stdout = io.StringIO() + with mock.patch.object(sys, "stdout", stdout): + code = setup_runtime_module.main(["claude", "--repo-dir", str(repo_dir), "--project", "demo"]) + self.assertEqual(code, 0) + self.assertIn("agent-memory setup claude", stdout.getvalue()) + def test_claude_removes_only_generated_session_end_push(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) @@ -537,11 +618,14 @@ def test_codex_hooks_merge_preserves_existing_values(self) -> None: project_name="demo", oacp_root=repo_dir / "oacp", ) + # The file exists and carries no managed entry, so setup leaves it + # alone: regeneration is its job, creation is not. data = json.loads(hooks_file.read_text(encoding="utf-8")) self.assertEqual(data["description"], "custom") self.assertIn("Stop", data["hooks"]) - self.assertIn("SessionStart", data["hooks"]) - self.assertIn(".codex/hooks.json", result["created_files"]) + self.assertNotIn("SessionStart", data["hooks"]) + self.assertIn(".codex/hooks.json", result["unmanaged_files"]) + self.assertNotIn(".codex/hooks.json", result["created_files"]) def test_codex_hook_registration_is_idempotent(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -601,6 +685,49 @@ def test_codex_hook_registration_replaces_changed_managed_entry(self) -> None: self.assertNotIn(str(first_root), commands[0]) self.assertIn(".codex/hooks.json", result["created_files"]) + def test_codex_hook_regeneration_replaces_managed_entry_for_literal_dollar_home( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + oacp_root = repo_dir / "oacp$HOME" + legacy = setup_runtime_module._codex_session_start_command( + project_name="first", oacp_root=oacp_root + ).replace("--hook", "--hook --pull-memory", 1) + hooks_file = repo_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True) + hooks_file.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + { + "matcher": "^startup$", + "hooks": [{"type": "command", "command": legacy}], + } + ] + } + } + ), + encoding="utf-8", + ) + + setup_runtime( + "codex", repo_dir=repo_dir, project_name="second", oacp_root=oacp_root + ) + + data = json.loads(hooks_file.read_text(encoding="utf-8")) + commands = [ + hook["command"] + for entry in data["hooks"]["SessionStart"] + for hook in entry.get("hooks", []) + ] + expected = setup_runtime_module._codex_session_start_command( + project_name="second", oacp_root=oacp_root + ) + self.assertEqual(commands, [expected]) + self.assertIn(f"--hub-dir '{oacp_root}'", expected) + def test_codex_hook_replacement_preserves_custom_session_start_hook(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) @@ -656,6 +783,97 @@ def test_codex_hook_replacement_preserves_custom_session_start_hook(self) -> Non self.assertEqual(len(managed), 1) self.assertIn("--project new", managed[0]) + def test_codex_hooks_empty_hooks_object_is_left_untouched(self) -> None: + """An existing file with no managed entry encodes "startup stays off".""" + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + hooks_file = repo_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True) + original = json.dumps( + { + "description": ( + "OACP automatic startup is disabled; initialize and " + "retrieve memory on demand." + ), + "hooks": {}, + }, + indent=2, + ) + hooks_file.write_text(original, encoding="utf-8") + + result = setup_runtime( + "codex", + repo_dir=repo_dir, + project_name="demo", + oacp_root=repo_dir / "oacp", + ) + + self.assertEqual(hooks_file.read_text(encoding="utf-8"), original) + self.assertIn(".codex/hooks.json", result["unmanaged_files"]) + self.assertNotIn(".codex/hooks.json", result["created_files"]) + self.assertNotIn(".codex/hooks.json", result["skipped_files"]) + + def test_codex_hooks_custom_only_session_start_is_left_untouched(self) -> None: + """A SessionStart list holding only a custom hook gains no managed entry.""" + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + hooks_file = repo_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True) + original = json.dumps( + { + "hooks": { + "SessionStart": [ + { + "matcher": "^startup$", + "hooks": [ + { + "type": "command", + "command": ".codex/hooks/custom-startup.sh", + } + ], + } + ] + } + }, + indent=2, + ) + hooks_file.write_text(original, encoding="utf-8") + + result = setup_runtime( + "codex", + repo_dir=repo_dir, + project_name="demo", + oacp_root=repo_dir / "oacp", + ) + + self.assertEqual(hooks_file.read_text(encoding="utf-8"), original) + self.assertIn(".codex/hooks.json", result["unmanaged_files"]) + + def test_codex_hooks_absent_file_is_created_with_the_managed_entry(self) -> None: + """Creation stays the behaviour when there is no hooks file at all.""" + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + hooks_file = repo_dir / ".codex" / "hooks.json" + self.assertFalse(hooks_file.exists()) + + result = setup_runtime( + "codex", + repo_dir=repo_dir, + project_name="demo", + oacp_root=repo_dir / "oacp", + ) + + self.assertIn(".codex/hooks.json", result["created_files"]) + self.assertEqual(result["unmanaged_files"], []) + data = json.loads(hooks_file.read_text(encoding="utf-8")) + commands = [ + hook["command"] + for entry in data["hooks"]["SessionStart"] + for hook in entry.get("hooks", []) + ] + self.assertEqual(len(commands), 1) + self.assertTrue(commands[0].startswith("oacp session-init --hook")) + def test_codex_hooks_warns_when_existing_file_is_not_object(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) @@ -689,6 +907,287 @@ def test_claude_settings_warns_when_existing_settings_is_not_object(self) -> Non self.assertIn("expected a JSON object", stderr.getvalue()) self.assertEqual(settings.read_text(encoding="utf-8"), "[]") + # --- migration pass: preservation guarantees ------------------------- + + @staticmethod + def _legacy_claude_settings(**extra_hooks): + hooks = { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + {"type": "command", "command": ".claude/hooks/oacp-memory-pull.sh"} + ], + } + ], + "SessionEnd": [ + {"hooks": [{"type": "command", "command": ".claude/hooks/oacp-memory-push.sh"}]} + ], + } + hooks.update(extra_hooks) + return {"hooks": hooks} + + def test_claude_keeps_hook_files_whose_bytes_differ(self) -> None: + exact_pull = setup_runtime_module.CLAUDE_LEGACY_MEMORY_PULL_HOOK.encode("utf-8") + exact_push = setup_runtime_module.CLAUDE_LEGACY_MEMORY_PUSH_HOOK.encode("utf-8") + cases = { + "crlf": (exact_pull.replace(b"\n", b"\r\n"), exact_push), + "non-utf8": (exact_pull, exact_push + b"# \xff\n"), + } + for label, (pull_bytes, push_bytes) in cases.items(): + with self.subTest(case=label), tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + settings = repo_dir / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps(self._legacy_claude_settings()), encoding="utf-8") + hooks_dir = repo_dir / ".claude" / "hooks" + hooks_dir.mkdir() + pull = hooks_dir / "oacp-memory-pull.sh" + push = hooks_dir / "oacp-memory-push.sh" + pull.write_bytes(pull_bytes) + push.write_bytes(push_bytes) + kept, retired = (pull, push) if label == "crlf" else (push, pull) + kept_bytes = kept.read_bytes() + + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + + # Exact bytes: retired. Any other byte sequence: kept as-is and + # reported, whether or not it decodes as UTF-8. + self.assertFalse(retired.exists()) + self.assertEqual(kept.read_bytes(), kept_bytes) + kept_rel = f".claude/hooks/{kept.name}" + self.assertEqual(result["retired_files"], [f".claude/hooks/{retired.name}"]) + self.assertIn(kept_rel, result["skipped_files"]) + data = json.loads(settings.read_text(encoding="utf-8")) + self.assertNotIn("SessionStart", data["hooks"]) + self.assertNotIn("SessionEnd", data["hooks"]) + + def test_claude_refused_settings_keeps_script_and_registration(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + settings = repo_dir / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + original = json.dumps(self._legacy_claude_settings(PreToolUse={"not": "a list"})) + settings.write_text(original, encoding="utf-8") + script = repo_dir / ".claude" / "hooks" / "oacp-memory-pull.sh" + script.parent.mkdir() + script.write_text(setup_runtime_module.CLAUDE_LEGACY_MEMORY_PULL_HOOK, encoding="utf-8") + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + + self.assertIn(".claude/settings.json", result["warning_files"]) + self.assertIn("expected hooks.PreToolUse to be a list", stderr.getvalue()) + self.assertEqual(settings.read_text(encoding="utf-8"), original) + self.assertTrue(script.is_file()) + self.assertEqual(result["retired_files"], []) + + def test_claude_unwritable_settings_keeps_script_and_registration(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + settings = repo_dir / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + original = json.dumps(self._legacy_claude_settings()) + settings.write_text(original, encoding="utf-8") + script = repo_dir / ".claude" / "hooks" / "oacp-memory-pull.sh" + script.parent.mkdir() + script.write_text(setup_runtime_module.CLAUDE_LEGACY_MEMORY_PULL_HOOK, encoding="utf-8") + real_write_text = Path.write_text + + def refuse_settings(self_path, *args, **kwargs): + if self_path.name == "settings.json": + raise OSError(30, "Read-only file system") + return real_write_text(self_path, *args, **kwargs) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), mock.patch.object( + Path, "write_text", autospec=True, side_effect=refuse_settings + ): + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + + self.assertIn(".claude/settings.json", result["warning_files"]) + self.assertIn("could not write", stderr.getvalue()) + self.assertEqual(settings.read_text(encoding="utf-8"), original) + self.assertTrue(script.is_file()) + self.assertEqual(result["retired_files"], []) + + def test_claude_keeps_script_still_named_by_an_unmanaged_registration(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + settings = repo_dir / ".claude" / "settings.json" + settings.parent.mkdir(parents=True) + # SessionStart in a shape the migration does not manage (an object, + # not a list) that still names the generated script. + settings.write_text( + json.dumps( + { + "hooks": { + "SessionStart": { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/oacp-memory-pull.sh", + } + ] + } + } + } + ), + encoding="utf-8", + ) + script = repo_dir / ".claude" / "hooks" / "oacp-memory-pull.sh" + script.parent.mkdir() + script.write_text(setup_runtime_module.CLAUDE_LEGACY_MEMORY_PULL_HOOK, encoding="utf-8") + + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + + self.assertIn(".claude/settings.json", result["created_files"]) + self.assertTrue(script.is_file()) + self.assertEqual(result["retired_files"], []) + self.assertIn(".claude/hooks/oacp-memory-pull.sh", result["skipped_files"]) + + def test_claude_keeps_script_reached_through_a_symlinked_hooks_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) / "repo" + elsewhere = Path(tmpdir) / "elsewhere" + (repo_dir / ".claude").mkdir(parents=True) + elsewhere.mkdir() + (repo_dir / ".claude" / "hooks").symlink_to(elsewhere, target_is_directory=True) + script = elsewhere / "oacp-memory-pull.sh" + script.write_text(setup_runtime_module.CLAUDE_LEGACY_MEMORY_PULL_HOOK, encoding="utf-8") + + result = setup_runtime("claude", repo_dir=repo_dir, project_name="demo") + + self.assertTrue(script.is_file()) + self.assertEqual(result["retired_files"], []) + self.assertIn(".claude/hooks/oacp-memory-pull.sh", result["skipped_files"]) + + def test_codex_session_start_command_grammar(self) -> None: + is_managed = setup_runtime_module._is_codex_session_start_command + generated_old = ( + "oacp session-init --hook --pull-memory --project demo --hub-dir /srv/oacp" + ) + generated_new = setup_runtime_module._codex_session_start_command( + project_name="demo", oacp_root=Path("/srv/oacp") + ) + generated_dollar = setup_runtime_module._codex_session_start_command( + project_name="demo", oacp_root=Path("/srv/oacp$1") + ) + generated_backtick = setup_runtime_module._codex_session_start_command( + project_name="de`mo", oacp_root=Path("/srv/oacp") + ) + managed = ( + "oacp session-init --hook", + "oacp session-init --hook --pull-memory", + "oacp session-init --hook --project demo", + "oacp session-init --hook --hub-dir '/srv/oacp home'", + "oacp session-init --hook --hub-dir '/srv/oacp home;x'", + generated_old, + generated_new, + generated_dollar, + generated_backtick, + "oacp session-init --hook --pull-memory --project demo --hub-dir '/srv/oacp$1'", + "oacp session-init --hook --hub-dir '$HOME/oacp'", + ) + for command in managed: + with self.subTest(command=command, expected="managed"): + self.assertTrue(is_managed(command)) + custom = ( + "oacp session-init --hook --project demo --dry-run && echo custom", + "oacp session-init --hook --project demo --dry-run", + "oacp session-init --hook --project demo ; echo custom", + "oacp session-init --hook --project demo | tee log", + "oacp session-init --hook --project demo;true", + "oacp session-init --hook --project demo&&true", + "oacp session-init --hook --project demo||true", + "oacp session-init --hook --project demo|tee log", + "oacp session-init --hook --project demo>/tmp/custom-log", + "oacp session-init --hook --project demo 2>/dev/null", + "oacp session-init --hook --project $(whoami)", + "oacp session-init --hook --project `whoami`", + "oacp session-init --hook --project $PROJECT", + "oacp session-init --hook --hub-dir $HOME/oacp", + 'oacp session-init --hook --hub-dir "$HOME/oacp"', + "oacp session-init --hook --hub-dir '$HOME'/oacp", + 'oacp session-init --hook --hub-dir "/srv/oacp`whoami`"', + "oacp session-init --hook --project demo #note", + "oacp session-init --hook --project demo --project other", + "oacp session-init --hook --project", + "oacp session-init --hook --project --hub-dir /srv/oacp", + "oacp session-init --project demo", + "oacp session-init --hook 'unterminated", + None, + ) + for command in custom: + with self.subTest(command=command, expected="custom"): + self.assertFalse(is_managed(command)) + + def test_codex_hook_replacement_preserves_custom_command_with_managed_prefix(self) -> None: + for custom in ( + "oacp session-init --hook --project demo --dry-run && echo custom", + "oacp session-init --hook --project demo;true", + "oacp session-init --hook --project demo&&true", + "oacp session-init --hook --project demo>/tmp/custom-log", + 'oacp session-init --hook --project demo --hub-dir "$HOME/oacp"', + ): + with self.subTest(custom=custom): + self._assert_custom_codex_command_preserved(custom) + + def _assert_custom_codex_command_preserved(self, custom: str) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + hooks_file = repo_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True) + hooks_file.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + { + "matcher": "^startup$", + "hooks": [ + { + "type": "command", + "command": custom, + "timeout": 5, + "statusMessage": "mine", + } + ], + } + ] + } + } + ), + encoding="utf-8", + ) + + original = hooks_file.read_text(encoding="utf-8") + + result = setup_runtime( + "codex", repo_dir=repo_dir, project_name="demo", oacp_root=repo_dir / "oacp" + ) + + self.assertEqual(hooks_file.read_text(encoding="utf-8"), original) + self.assertIn(".codex/hooks.json", result["unmanaged_files"]) + data = json.loads(hooks_file.read_text(encoding="utf-8")) + hooks = [ + hook + for entry in data["hooks"]["SessionStart"] + for hook in entry.get("hooks", []) + ] + custom_hooks = [hook for hook in hooks if hook["command"] == custom] + self.assertEqual(len(custom_hooks), 1) + self.assertEqual(custom_hooks[0]["timeout"], 5) + self.assertEqual(custom_hooks[0]["statusMessage"], "mine") + # The custom command only *looks* managed, so this file carries no + # managed entry: setup adds none and the bytes are unchanged. + managed = [ + hook["command"] + for hook in hooks + if setup_runtime_module._is_codex_session_start_command(hook["command"]) + ] + self.assertEqual(managed, []) if __name__ == "__main__": unittest.main()