---
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).
