cc-os/docs/adr/0016-memory-plugin-sourced-...

8.0 KiB

id date status supersedes superseded-by affected-paths affected-components migration_confidence migration_source
0016 2026-06-12 Accepted
low 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).