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 tocc-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() -> Configfrozen dataclass — handles YAML parse, path expansion, defaults, missing-file case. Replaces 3 separate inline YAML parsers.hook_io.py:read_input() -> HookInputdataclass (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_IDtemp-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 intry/except: uncaught exceptions log to stderr andexit 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 entrymemsearch_sync.py, registered as a separate hook in~/.claude/settings.jsonwith a 30-second timeout. This is a relocation of ADR-015 behavior, not a reversal — the auto-commit+push logic, whitelist.gitignore, fail-safe|| truecontract, 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-pluginsmarketplace source path tocc-os/plugins/os-vault/. In practice, there is nomarketplace.jsonregistry manifest to repoint — the marketplace'sknown_marketplaces.jsonmanages marketplace sources, not per-plugin paths, and the hook scripts are entirely decoupled from the marketplace (they are registered insettings.jsonby hardcoded absolute path). The functional cutover was therefore accomplished via two actions: (1)~/.claude/settings.jsonhook entries were rewritten to invoke/usr/bin/python3with absolute paths intocc-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.shmixed 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.jsonfor 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
Hookclass 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-vaultis a symlink; do not replace it with a directory (e.g. on plugin reinstall) without checking cc-os source first.~/.claude/settings.jsonhook entries now invoke Python 3 via absolute path into cc-os; keep these entries current when hook filenames change.- The fail-open invariant (
exit 0on any unhandled exception) is a hard contract: never wrap a hook'smain()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).