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

112 lines
8.0 KiB
Markdown

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