--- id: "0016" date: 2026-06-12 status: Accepted supersedes: superseded-by: affected-paths: [] affected-components: [] migration_confidence: low migration_source: "docs/memory-system/03-architecture-decisions.md### ADR-016 — Memory plugin sourced from cc-os git repo; bash→Python deep-module port; memsearch-sync split; symlink cutover" --- # 0016 — Memory plugin sourced from cc-os git repo; bash→Python deep-module port; memsearch-sync split; symlink cutover ## Context The global Claude Code os-vault plugin was developed iteratively in-place at `~/.claude/plugins/os-vault/` — never tracked in version control. Four bash hook scripts (`session-start.sh`, `session-context.sh`, `post_tool_use_write.sh`, `session_end.sh`) each independently parsed YAML config, decoded stdin JSON, and referenced shared filesystem contracts (e.g. a temp file keyed by `$SESSION_ID`) via ad-hoc inline code with no abstraction boundary. Additionally, the memsearch auto-commit+push block (ADR-015 behavior) was embedded in `session_end.sh` alongside the vault journal logic. The plugin was untracked and fragile; the bash scripts had no shared modules, no tests, and a jq-vs-python3 inconsistency across hooks. An OpenSpec change (`memory-plugin-source-and-port`) was initiated to move the plugin into git and port the hooks to Python. - **Decision (1) — Source location**: The canonical source for the global os-vault plugin is now `cc-os/plugins/os-vault/` (this repo, git-tracked). `~/.claude/plugins/os-vault/` is a symlink pointing to `cc-os/plugins/os-vault/`; it is no longer an independent directory. - **Decision (2) — Module shape: deep-module Python architecture**: The four bash hooks were ported to Python as a **deep-module** design — three shared modules providing clean, stable abstractions, plus thin entry-point scripts: - `config.py`: `load_config() -> Config` frozen dataclass — handles YAML parse, path expansion, defaults, missing-file case. Replaces 3 separate inline YAML parsers. - `hook_io.py`: `read_input() -> HookInput` dataclass (session_id, cwd, file_path, reason) — replaces 4 separate inline stdin parsers and eliminates the jq-vs-python3 inconsistency. - `session_state.py`: `record_touch(session_id, path)` / `read_touches(session_id) -> list[str]` — encapsulates the `/tmp/claude-vault-touched-$SESSION_ID` temp-file contract. The temp file still exists by necessity (separate process invocations); the win is an explicit, named, testable contract. - Thin entry-point scripts: `hooks/session_start.py`, `hooks/session_context.py`, `hooks/post_tool_use_write.py`, `hooks/session_end.py`, `hooks/memsearch_sync.py`. - Every `main()` is wrapped in `try/except`: uncaught exceptions log to stderr and `exit 0` (fail-open invariant — a hook crash cannot block a session). - **Decision (3) — memsearch-sync split**: The memsearch auto-commit+push block from ADR-015 was extracted out of `session_end.py` (vault journal) into its own dedicated SessionEnd hook entry `memsearch_sync.py`, registered as a separate hook in `~/.claude/settings.json` with a 30-second timeout. This is a **relocation of ADR-015 behavior, not a reversal** — the auto-commit+push logic, whitelist `.gitignore`, fail-safe `|| true` contract, and Forgejo remote are byte-for-byte preserved. The benefit is separation of concerns: vault journal failures and memsearch sync failures are now independently observable and independently fail-open. - **Decision (4) — Deployment-mechanism deviation (symlink)**: The OpenSpec plan described repointing the `local-plugins` marketplace source path to `cc-os/plugins/os-vault/`. In practice, there is no `marketplace.json` registry manifest to repoint — the marketplace's `known_marketplaces.json` manages marketplace sources, not per-plugin paths, and the hook scripts are entirely decoupled from the marketplace (they are registered in `settings.json` by hardcoded absolute path). The functional cutover was therefore accomplished via two actions: (1) `~/.claude/settings.json` hook entries were rewritten to invoke `/usr/bin/python3` with absolute paths into `cc-os/plugins/os-vault/hooks/`, which is the real behavioral cutover; (2) a symlink `~/.claude/plugins/os-vault → cc-os/plugins/os-vault/` was created so that skill resolution (which follows the plugins directory) continues to resolve correctly. Pre-cutover backups were taken: `~/.claude/settings.json.bak-precutover-2026-06-12`, `~/.claude/known_marketplaces.json.bak-precutover-2026-06-12`, and `~/.claude/plugins/os-vault.bak-bash-precutover/`. - **Rationale**: - **Source in git**: an untracked plugin is invisible to review, diff, and rollback; living in cc-os gives change history, golden fixtures, and CI-comparable testing. - **Deep-module over OO hook-class hierarchy**: four hooks that run as separate processes with no shared runtime have nothing to gain from a class hierarchy — inheritance is just complexity. Shared modules with named functions and typed dataclasses give all the benefits (single parse path, named contracts) with no class machinery. The three modules provide a deep public surface (one function call = full parse + validation) behind a thin API, which is the Ousterhout deep-module criterion. - **memsearch-sync split**: the original monolithic `session_end.sh` mixed two distinct concerns (vault journal vs external git push). Split entry points mean each has its own timeout budget, its own failure domain, and its own log line. - **Symlink + settings.json rewrite**: the symlink is the minimum viable mechanism consistent with how Claude Code actually resolves plugins. Attempting to retool `known_marketplaces.json` for per-plugin path overrides would have required reverse-engineering an undocumented format; direct hook path rewriting in settings.json is explicit, transparent, and reversible. ## Decision _not stated in source_ ## Consequences The os-vault plugin moved from an untracked in-place directory to git-tracked source in cc-os (with a symlink at the old location), its four bash hooks were ported to a deep-module Python architecture (shared config/hook_io/session_state modules plus thin entry scripts, each wrapped in try/except to fail open), and the memsearch auto-commit+push logic was split out of the vault journal hook into its own dedicated SessionEnd entry. Ongoing contracts include never replacing the plugin symlink with a plain directory and keeping settings.json's absolute hook paths current when filenames change; a fresh-session cutover test passed 2026-06-12. ## Alternatives rejected - **OO hook-class hierarchy**: a base `Hook` class with subclasses per hook type. Rejected because hooks run in separate processes — there is no shared runtime state to inherit. Shared logic belongs in functions, not class trees. - **Keep bash, just move into git**: bash lacks typed dataclasses, structured exceptions, and unit-testable modules. The jq-vs-python3 inconsistency (pre-existing bug) would be carried forward unchanged, and the temp-file contract would remain implicit. - **Repoint `known_marketplaces.json`**: no per-plugin path override semantics confirmed in the actual marketplace format; would require undocumented hacking with no rollback path. - **Consequences / ongoing contracts**: - `~/.claude/plugins/os-vault` is a symlink; do not replace it with a directory (e.g. on plugin reinstall) without checking cc-os source first. - `~/.claude/settings.json` hook entries now invoke Python 3 via absolute path into cc-os; keep these entries current when hook filenames change. - The fail-open invariant (`exit 0` on any unhandled exception) is a hard contract: never wrap a hook's `main()` in code that propagates exceptions to the harness. - A fresh-session cutover test was run 2026-06-12 and passed: all five hooks fired from the cc-os Python sources. - **Cross-references**: ADR-009 (global plugin design); ADR-015 (memsearch sync — relocated, not reversed).