## 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 the `python3`-vs-`jq` inconsistency.
- `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/`: thin `main()` 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.
