6.0 KiB
Context
The memory plugin currently lives only at ~/.claude/plugins/memory/ — outside any git repo, installed via memory@local-plugins. The cc-os repository governs the plugin's design through ADRs and specs but contains no plugin source code. The four bash hooks (session_start.sh, session_context.sh, post_tool_use_write.sh, session_end.sh) each re-derive their own config and stdin parsing plumbing independently.
Goals / Non-Goals
Goals:
- Establish
cc-os/plugins/memory/as the single version-controlled source for the plugin - Replace duplicated inline bash parsers with a Python deep-module (
config.py,hook_io.py,session_state.py) - Ensure every hook
main()is fail-open (uncaught exceptions exit 0 and log; never disrupt a session) - Produce a behavior-faithful port: golden fixtures verified before any cleanup or refactor
- Split memsearch git-sync into its own hook entry
Non-Goals:
- Changing memsearch sync behavior or destination (ADR-015 governs that; this is relocation only)
- Adding new memory features or modifying vault conventions
- Bulk vault migration or any changes to the Obsidian vault structure
Decisions
Decision 1 — Source location: cc-os/plugins/memory/
Rationale: keeps plugin code beside the ADRs that justify every design choice. One PR ties code to rationale. A separate cc-plugins repo was considered and rejected — it would re-sever design from code without any benefit at this scale.
Decision 2 — Module shape: deep functions + dataclasses; no OO class hierarchy
Rationale: for four ~50-line hooks, an OO hierarchy (AbstractHook, ConfigProvider, GraphifyOrchestrator, etc.) multiplies shallow interfaces where the interface becomes as complex as the implementation — fails the deletion test. Deep functions with named dataclasses (Config, HookInput) surface the same abstraction with less indirection.
Modules:
config.py→load_config() -> Config(frozen dataclass). All "parse YAML / expand paths / apply defaults / handle missing file" complexity behind one call. Replaces 3 duplicated inline YAML parsers.hook_io.py→read_input() -> HookInput(dataclass: session_id, cwd, file_path, reason). Replaces 4 inline stdin parsers and thepython3-vs-jqinconsistency.session_state.py→record_touch(session_id, path)/read_touches(session_id). Makes the post-tool-use-write → session-end handoff (currently/tmp/claude-vault-touched-$SESSION_ID) an explicit, named, testable contract. NOTE: this remains a state FILE by necessity — the two hooks are separate process invocations with no shared memory; the win is an explicit contract, not removing the file.hooks/: thinmain()entries —session_start.py,session_context.py,post_tool_use_write.py,session_end.py(vault journal only),memsearch_sync.py(split out).
Decision 3 — memsearch git-sync as its own hook entry Rationale: session-end currently does two unrelated things (vault journal write and memsearch git sync). Splitting them makes each hook testable in isolation and makes the SessionEnd hook list explicit about what fires. ADR-015 STILL HOLDS — it governs WHERE and THAT memsearch syncs (dedicated Forgejo repo, auto-sync via a cc-os SessionEnd hook). This split is a relocation of which file performs the sync, not a reversal of ADR-015.
Invariants (must be preserved through the port):
- Fail-open: every
main()wraps execution in try/except; uncaught exceptions log and exit 0. A hook must never disrupt a session. - No new runtime dependency: Python + PyYAML are already assumed present (current hooks already use them).
- Behavior-faithful port FIRST: capture golden fixtures (known stdin → snapshot side effects) for all 4 bash hooks, port to Python, assert identical output, THEN clean up. No behavior changes smuggled into the port.
Risks / Trade-offs
Risk: marketplace repoint mechanism is unverified
The exact mechanism to repoint the local-plugins marketplace from ~/.claude/plugins/ to cc-os/plugins/memory/ (source-path change vs symlink vs marketplace re-registration) is an open item. Must be verified before cutover — do not assume either approach. Marketplace repoint is the LAST functional task.
Risk: Python shebang / PATH in hook invocation
Bash hooks use #!/usr/bin/env bash; Python replacements need equivalent. Verify python3 resolves correctly in the Claude Code hook subprocess environment before cutover.
Trade-off: two files for session-state Splitting session-end means the touched-paths state file contract is now between two Python files instead of two bash files. The contract becomes more explicit (named function, typed return), but the file still exists — this is an improvement in clarity, not a removal of the mechanism.
Open Items / Findings
Finding from golden-fixture capture (task group 1): session-end.sh hardcodes MEMSEARCH_DIR=\"/home/jared/.memsearch\", an absolute path that bypasses HOME/config and is invisible to config.yaml. When splitting out memsearch_sync.py, this path must become a Config field (default ~/.memsearch) rather than another hardcoded constant — exposing buried coupling the deep-module port is meant to surface.
Finding from parity run (task group 4): generate-fixtures.sh mid-run safety checks (after each session-end scenario) originally compared against the hardcoded EXPECTED_HEAD. In --replay-dir mode this false-positives a breach (exit 99) whenever the real memsearch HEAD has advanced since the golden fixtures were generated. Changed those two mid-run checks to compare against $REAL_HEAD_BEFORE (the true before==after invariant, matching the pre-run guard's own logic and comment). Safety semantics are unchanged; the strict EXPECTED_HEAD equality remains enforced only for the non-replay golden-generation path. Python port parity confirmed: diff -r tests/golden/ <replay> produces zero bytes, real memsearch HEAD unchanged across the full run, git-shim blocked all three ~/.memsearch operations.