Source memory plugin in git; port hooks to Python; archive change

- Move memory plugin source from ~/.claude/plugins/memory (untracked) into cc-os/plugins/memory under git
- Port four bash hooks to Python deep-module architecture (config.py, hook_io.py, session_state.py + thin entry points)
- Split memsearch auto-commit+push into dedicated memsearch_sync.py SessionEnd hook (relocation of ADR-015, behavior preserved)
- Cutover via symlink + settings.json rewrite; fresh-session test verified all hooks fire
- Add ADR-016; update CLAUDE.md, build plan, and fix stale bash-hook/source-path references
- Sync three new-capability delta specs into openspec/specs; archive change as 2026-06-12-memory-plugin-source-and-port

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jared 2026-06-12 12:42:05 -04:00
parent 53a3c4c2c2
commit 48e4d77795
96 changed files with 2727 additions and 21 deletions

View File

@ -61,17 +61,20 @@ to those two and fix the stale doc.
**Empirical finding locked (2026-06-05):** Graphify is a structure extractor, not a topic clusterer — no emergent hub nodes appear even at `--mode deep`; hub notes + wikilinks must be author-provided during migration (not deferred). Migration scaffolding is now a first-class deliverable. Open question: do facet tags create graph edges? (ADR-014; findings: `docs/memory-system/07-graph-connectivity-findings.md`).
**Implementation status (2026-06-09):** The global Claude Code plugin is live (`~/.claude/plugins/memory/`). Steps 1, 2a, 2b, 3, and 6 of the build plan are complete (including the `memory-project` skill, previously TODO under Step 6/Part D). Step 1 completed 2026-06-09: vault-conventions.md exists, all 6 fixture notes seeded, and 14 Graphify handbook + memory-system design notes migrated to the vault as migration scaffolding. Step 4 (memsearch) completed 2026-06-09: plugin installed via marketplace, MEMSEARCH_DIR set global (~/.memsearch), Stop hook verified producing daily memory files, search confirmed working. Step 5a (memsearch episodic git sync) completed 2026-06-09: dedicated private Forgejo repo (`forgejo.swansoncloud.com/jared/memsearch`), whitelist `.gitignore` (memory/*.md only), auto-commit+push via cc-os `session-end.sh` hook (ADR-015). Step 5b (Obsidian vault → VPS sync) remains. See `docs/memory-system/04-build-plan.md` for full step status.
**Implementation status (2026-06-09):** The global Claude Code plugin is live (`~/.claude/plugins/memory/`). Steps 1, 2a, 2b, 3, and 6 of the build plan are complete (including the `memory-project` skill, previously TODO under Step 6/Part D). Step 1 completed 2026-06-09: vault-conventions.md exists, all 6 fixture notes seeded, and 14 Graphify handbook + memory-system design notes migrated to the vault as migration scaffolding. Step 4 (memsearch) completed 2026-06-09: plugin installed via marketplace, MEMSEARCH_DIR set global (~/.memsearch), Stop hook verified producing daily memory files, search confirmed working. Step 5a (memsearch episodic git sync) completed 2026-06-09: dedicated private Forgejo repo (`forgejo.swansoncloud.com/jared/memsearch`), whitelist `.gitignore` (memory/*.md only), auto-commit+push via cc-os `session-end.sh` hook (ADR-015; relocated to `memsearch_sync.py` by ADR-016, 2026-06-12). Step 5b (Obsidian vault → VPS sync) remains. See `docs/memory-system/04-build-plan.md` for full step status.
**Decision (2026-06-09):** Single global memsearch store for ALL clients — cross-client commingling is the accepted, intended design (ADR-015 resolved). One private Forgejo repo (`forgejo.swansoncloud.com/jared/memsearch`), not per-client repos. Forward direction: minimize/eliminate dedicated per-client working directories; work projects locally or use a single general `clients/` dir for non-local client work — memory is captured globally regardless of cwd. No `clients/` directory structure designed yet; open item.
**Implementation status (2026-06-12):** Memory plugin source moved into git at `cc-os/plugins/memory/` and bash hooks ported to Python (deep-module architecture: shared `config.py`, `hook_io.py`, `session_state.py`; thin entry-point scripts). Cutover via symlink `~/.claude/plugins/memory → cc-os/plugins/memory/` and settings.json hook rewrite. memsearch sync split into dedicated `memsearch_sync.py` SessionEnd hook (relocation of ADR-015 behavior, not reversal). Fresh-session test passed 2026-06-12. See ADR-016.
## Implemented Components
**Global memory plugin** — `~/.claude/plugins/memory/`
- Hooks: `hooks/` — session-start.sh, session-context.sh (project graph path only), post-tool-use-write.sh, session-end.sh
**Global memory plugin** — `cc-os/plugins/memory/` (git-tracked, 2026-06-12); symlinked into `~/.claude/plugins/memory`
- Hooks: `hooks/``session_start.py`, `session_context.py` (project graph path only), `post_tool_use_write.py`, `session_end.py` (vault journal), `memsearch_sync.py` (second SessionEnd hook; memsearch auto-commit+push, 30s timeout)
- Shared modules: `config.py` (load_config → frozen Config dataclass), `hook_io.py` (read_input → HookInput dataclass), `session_state.py` (record_touch/read_touches; encapsulates `/tmp/claude-vault-touched-$SESSION_ID` contract)
- Skills: `skills/` — memory-vault, memory-write, memory-reorganize, memory-project
- Config: `config.yaml` — vault path, Ollama model (qwen25-coder-7b-16k), env vars
- Hook wiring: `~/.claude/settings.json`
- Hook wiring: `~/.claude/settings.json` (hook entries invoke `/usr/bin/python3` with absolute paths into cc-os)
**Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify`
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, rebuilt by SessionStart hook
@ -84,7 +87,7 @@ to those two and fix the stale doc.
- Index: `~/.memsearch/milvus.db` (Milvus Lite, local); embeddings via ONNX bge-m3
- Config: `MEMSEARCH_DIR=~/.memsearch` in `~/.zshrc` for global scope
- Skills: `/memory-recall`, `/memory-config` (ship with plugin)
- Git sync: `~/.memsearch` is a dedicated private Forgejo repo (`forgejo.swansoncloud.com/jared/memsearch`); whitelist `.gitignore` commits only `memory/*.md` (excludes rebuildable `milvus.db`, model, config); auto-commit+push wired into the cc-os memory plugin's own `session-end.sh` hook (see ADR-015)
- Git sync: `~/.memsearch` is a dedicated private Forgejo repo (`forgejo.swansoncloud.com/jared/memsearch`); whitelist `.gitignore` commits only `memory/*.md` (excludes rebuildable `milvus.db`, model, config); auto-commit+push wired into the cc-os memory plugin's own `memsearch_sync.py` SessionEnd hook (relocated from `session-end.sh` by ADR-016, 2026-06-12; behavior preserved — see ADR-015)
**Not yet implemented:** Obsidian vault → VPS sync (Step 5b). Memsearch episodic git sync is done (Step 5a, 2026-06-09).

View File

@ -1,6 +1,6 @@
# Architecture Decision Records
_Last updated: 2026-06-09_
_Last updated: 2026-06-12_
A running log of decisions and *why*. Format per entry: Context · Decision · Rationale ·
Alternatives rejected · Status. Newest decisions extend the log; supersede rather than delete.
@ -427,7 +427,100 @@ _Date: 2026-06-09_
daily file per day, append-only). Git was already chosen for ADR-008's vault sync rationale;
applying the same mechanism to the episodic store is consistent.
- **Status**: Accepted (commingling resolved 2026-06-09). Initial commit `106cebc` in the
Forgejo repo; git repo initialized 2026-06-09 via the `git-context:repo-init` skill.
Forgejo repo; git repo initialized 2026-06-09 via the `git-context:repo-init` skill. The
memsearch auto-commit+push was relocated to a dedicated SessionEnd hook entry
(`memsearch_sync.py`) by ADR-016, 2026-06-12; behavior preserved.
## ADR-016 — Memory plugin sourced from cc-os git repo; bash→Python deep-module port; memsearch-sync split; symlink cutover
_Date: 2026-06-12_
- **Context**: The global Claude Code memory plugin was developed iteratively in-place at
`~/.claude/plugins/memory/` — 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 memory plugin is now
`cc-os/plugins/memory/` (this repo, git-tracked). `~/.claude/plugins/memory/` is a symlink
pointing to `cc-os/plugins/memory/`; 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/memory/`. 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/memory/hooks/`, which is the real behavioral cutover;
(2) a symlink `~/.claude/plugins/memory → cc-os/plugins/memory/` 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/memory.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.
- **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/memory` 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).
- **Status**: Accepted. Cutover verified 2026-06-12.
## Rejected tools (summary)

View File

@ -1,6 +1,6 @@
# Build Plan
_Last updated: 2026-06-09_
_Last updated: 2026-06-12_
How a human builds this system, step by step, and answers to the operational questions:
which scripts and hooks, how the AI knows when to write and what conventions to follow, how and
@ -208,7 +208,7 @@ A whitelist `.gitignore` tracks **only** `memory/*.md` (the daily session files)
`.gitignore`. Excluded as derived/disposable: `milvus.db` (Milvus Lite index — rebuildable via
`memsearch index`), `config.toml`, and the bge-m3 ONNX model (lives in `~/.cache/huggingface`).
Auto-commit and push are wired into the cc-os memory plugin's **own `session-end.sh`** hook
Auto-commit and push are wired into the cc-os memory plugin's **own `memsearch_sync.py`** SessionEnd hook (split from `session_end.py` by ADR-016, 2026-06-12; behavior preserved — see ADR-015)
(not the marketplace plugin, which can be clobbered on update). The block guards on
`~/.memsearch/.git` existing, commits only when staged content exists (message:
`memsearch: session memory <date>`), and pushes with `timeout 30 ... || true`. Wrapped in a
@ -229,6 +229,14 @@ across all clients by intent — see ADR-015, resolved 2026-06-09).
4 skills (`memory-vault`, `memory-write`, `memory-reorganize`, `memory-project`). Plugin enabled in
`~/.claude/settings.json`.
**Source-in-git + bash→Python port — DONE (2026-06-12):** Plugin source moved into git at
`cc-os/plugins/memory/` (OpenSpec change `memory-plugin-source-and-port`). Bash hooks ported to
Python using a deep-module architecture (shared `config.py`, `hook_io.py`, `session_state.py`;
thin entry-point scripts under `hooks/`). memsearch sync split into `memsearch_sync.py` as a
dedicated second SessionEnd hook (ADR-015 behavior relocated, not reversed). Cutover done via
symlink `~/.claude/plugins/memory → cc-os/plugins/memory/` and settings.json hook rewrite to
Python absolute paths. Fresh-session test passed 2026-06-12. See ADR-016.
### Step 7 — (SKIPPED) QMD semantic layer
Covered by Graphify. The knowledge-graph approach provides structured semantic retrieval
without vectors. Only revisit if Graphify's graph queries prove insufficient for a use case
@ -265,7 +273,7 @@ project-ephemeral state (that's the episodic layer / project files). Concretely:
- Add `--budget N` to cap answer size (default ~2000 tokens); `--dfs --budget N` for bounded traversal
- Add `--graph <path>` to query a specific project's `graph.json` instead of the vault graph
**At session start**: the session-context.sh hook emits the project graph path (pointing at
**At session start**: the session_context.py hook emits the project graph path (pointing at
`<project-root>/graphify-out/graph.json`) if one exists — vault context injection (god-node
summary, convention summaries, journal pointer) was removed. The AI queries the vault or
project graph on demand rather than having it injected.
@ -297,7 +305,7 @@ SEMrush concept, regardless of which client project they came from. Graph edges
Hooks are thin shell wrappers; the logic lives in Graphify.
**Hooks summary:**
- **SessionStart** — stale check + `--force` if needed, then inject project graph path (vault context injection was removed; session-context.sh now only emits the project graph path).
- **SessionStart** — stale check + `--force` if needed, then inject project graph path (vault context injection was removed; session_context.py now only emits the project graph path).
- **PostToolUse** (on `Write`/`Edit` of vault `.md`) — `graphify update --file`.
- **SessionEnd** — append daily journal note.
- (memsearch brings its own hooks for the episodic layer.)

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-11

View File

@ -0,0 +1,56 @@
## 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.

View File

@ -0,0 +1,28 @@
## Why
The implemented memory plugin lives only at `~/.claude/plugins/memory/` — installed via `memory@local-plugins` with source directory `/home/jared/.claude/plugins`. In-place edits are clobberable by a marketplace refresh, and changes are unversioned. `cc-os` governs the plugin's design (docs/memory-system/, openspec/, ADRs) but not its code — the repo declared as source-of-truth contains no source. This is the "no source seam" finding: design and code are severed.
Secondary friction: the four bash hooks each re-derive their own plumbing. `config.yaml` is parsed by three separate inline parsers (two identical); stdin JSON is parsed inconsistently (`python3` in session-start/session-context vs `jq` in post-tool-use-write/session-end). Renaming a single config key like `vault_path` requires edits across 4+ files.
## What Changes
1. Move plugin source into `cc-os/plugins/memory/` (tracked in git, beside the ADRs that justify it). Repoint the `local-plugins` marketplace to install from that path.
2. Port the four bash hooks to Python as a deep module of functions + dataclasses (explicitly NOT an OO class hierarchy): `config.py`, `hook_io.py`, `session_state.py`, thin hook `main()` entry points.
3. Split the memsearch git-sync out of `session_end` into its own hook entry (`memsearch_sync.py`).
## Capabilities
### New Capabilities
- `plugin-source`: Plugin source lives in cc-os git repo; local-plugins marketplace points to cc-os/plugins/memory/
- `hook-python-port`: Four bash hooks ported to Python deep-module; config parsed once; stdin parsed uniformly; session-state handoff made an explicit named contract
### Modified Capabilities
- `memsearch-sync`: memsearch git-sync split from session-end into its own hook entry (relocation only — ADR-015 governs where/that memsearch syncs; this change does not alter that decision)
## Impact
- `~/.claude/plugins/memory/` — source will be replaced or symlinked to `cc-os/plugins/memory/` after cutover
- `~/.claude/settings.json` — hook entries updated to invoke Python scripts instead of bash
- `docs/memory-system/03-architecture-decisions.md` — ADR-016 to be added at archive time
- `CLAUDE.md` Implemented Components — plugin source pointer updated at archive time
- No external runtime dependency added (Python + PyYAML already assumed present)

View File

@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: Single config loader
A `config.py` module SHALL expose `load_config() -> Config` (frozen dataclass) that parses `config.yaml`, expands paths, applies defaults, and handles missing file — replacing all duplicated inline YAML parsers.
#### Scenario: Config loaded once per hook invocation
- **WHEN** any hook script runs
- **THEN** it calls `load_config()` exactly once and accesses all config values through the returned `Config` dataclass
### Requirement: Uniform stdin parser
A `hook_io.py` module SHALL expose `read_input() -> HookInput` (dataclass with fields: session_id, cwd, file_path, reason) that parses hook stdin JSON, replacing all inline stdin parsers and eliminating the `python3`-vs-`jq` inconsistency.
#### Scenario: stdin parsed uniformly across all hooks
- **WHEN** any of the five hook entry points (session_start, session_context, post_tool_use_write, session_end, memsearch_sync) runs
- **THEN** stdin is parsed via `read_input()`, not inline shell commands or ad-hoc python
### Requirement: Named session-state contract
A `session_state.py` module SHALL expose `record_touch(session_id, path)` and `read_touches(session_id) -> list[str]`, encapsulating the `/tmp/claude-vault-touched-$SESSION_ID` temp-file handoff between post-tool-use-write and session-end hooks.
#### Scenario: Touch handoff uses named functions
- **WHEN** post_tool_use_write records a vault touch
- **THEN** it calls `record_touch(session_id, path)` rather than writing the temp file directly
#### Scenario: Session-end reads touches via named function
- **WHEN** session_end reads which vault files were touched
- **THEN** it calls `read_touches(session_id)` rather than reading the temp file directly
### Requirement: Fail-open hook mains
Every hook `main()` function MUST wrap execution in try/except; uncaught exceptions MUST log the error and exit with code 0.
#### Scenario: Exception does not disrupt session
- **WHEN** a hook raises an uncaught exception
- **THEN** the hook logs the exception to stderr and exits 0 (session continues normally)
### Requirement: Behavior-faithful port with golden fixtures
The Python port MUST demonstrate identical output to the bash hooks against golden fixtures before any cleanup or refactoring.
#### Scenario: Golden fixtures captured before port
- **WHEN** the port task begins
- **THEN** golden fixture files exist for each of the 4 bash hooks (known stdin → snapshot of side effects)
#### Scenario: Python output matches bash output
- **WHEN** Python hooks are run against golden fixtures
- **THEN** output matches the bash hook snapshots exactly before any refactor proceeds

View File

@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: memsearch git-sync in dedicated hook entry
The memsearch git-sync operation SHALL execute from its own hook entry point (`memsearch_sync.py`) rather than from `session_end.py`.
#### Scenario: session-end handles vault journal only
- **WHEN** a session ends
- **THEN** `session_end.py` handles only vault journal writes; it does NOT perform any memsearch git operations
#### Scenario: memsearch-sync hook handles git operations
- **WHEN** a session ends
- **THEN** `memsearch_sync.py` runs as a separate SessionEnd hook entry and performs the memsearch git commit+push
### Requirement: ADR-015 behavior preserved
The memsearch git-sync MUST continue to auto-commit and push to `forgejo.swansoncloud.com/jared/memsearch` on session end, exactly as specified by ADR-015. This split is a relocation of which file performs the sync, not a change to sync behavior or destination.
#### Scenario: Sync still fires on session end
- **WHEN** a session ends after cutover
- **THEN** memsearch daily memory files are committed and pushed to the Forgejo repo, identical to pre-cutover behavior

View File

@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Plugin source in cc-os repo
Plugin source SHALL live at `cc-os/plugins/memory/` under git version control.
#### Scenario: Source is tracked in git
- **WHEN** a developer inspects the cc-os repository
- **THEN** `plugins/memory/` directory exists with all hook scripts and plugin config under git tracking
### Requirement: Marketplace points to cc-os source
The `local-plugins` marketplace entry for `memory` SHALL reference `cc-os/plugins/memory/` as its source path.
#### Scenario: Plugin loads from cc-os path
- **WHEN** Claude Code starts a session after cutover
- **THEN** the memory plugin is loaded from `cc-os/plugins/memory/` (not `~/.claude/plugins/memory/`)
### Requirement: No unversioned in-place edits
Plugin files at `~/.claude/plugins/memory/` SHALL NOT be the authoritative source after cutover.
#### Scenario: Edit is versioned
- **WHEN** a developer modifies a hook script
- **THEN** the change is made in `cc-os/plugins/memory/`, committed to git, and takes effect on next session load

View File

@ -0,0 +1,39 @@
## 1. Capture current behavior (golden fixtures)
- [x] 1.1 For each of the 4 bash hooks (`session_start.sh`, `session_context.sh`, `post_tool_use_write.sh`, `session_end.sh`), write a golden fixture: known stdin JSON → snapshot of side effects (journal append content, rebuild-stamp invalidation, touched-paths temp file contents, parsed config env vars)
- [x] 1.2 Store fixtures under `cc-os/plugins/memory/tests/fixtures/` with one subdirectory per hook
- [x] 1.3 Confirm each fixture can be run against the current bash hook and produces the expected snapshot output
## 2. Move plugin source into cc-os (no behavior change)
- [x] 2.1 Copy `~/.claude/plugins/memory/` verbatim into `cc-os/plugins/memory/` under git (bash hooks, config.yaml, skills — exactly as-is)
- [x] 2.2 Commit to git; confirm `git status` shows only the new `plugins/memory/` tree, no diffs to existing files
- [x] 2.3 Do NOT yet repoint the marketplace or change any hook behavior — this establishes the seam only
## 3. Port to Python deep module
- [x] 3.1 Implement `config.py`: `load_config() -> Config` frozen dataclass; handles YAML parse, path expansion, defaults, missing file — replaces all 3 inline parsers
- [x] 3.2 Implement `hook_io.py`: `read_input() -> HookInput` dataclass (session_id, cwd, file_path, reason); replaces all 4 inline stdin parsers and the `python3`-vs-`jq` inconsistency
- [x] 3.3 Implement `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 (file still exists by necessity — separate process invocations; the win is an explicit named contract)
- [x] 3.4 Implement thin hook entry points: `hooks/session_start.py`, `hooks/session_context.py`, `hooks/post_tool_use_write.py`, `hooks/session_end.py` (vault journal only)
- [x] 3.5 Implement `hooks/memsearch_sync.py` as a separate SessionEnd hook entry (split from session-end); preserve exact ADR-015 sync behavior (auto-commit+push to `forgejo.swansoncloud.com/jared/memsearch`)
- [x] 3.6 Wrap every `main()` in try/except: uncaught exceptions log to stderr and exit 0 (fail-open invariant)
## 4. Verify parity against golden fixtures
- [x] 4.1 Run each Python hook entry point against the golden fixtures from task 1; assert output is identical to the bash snapshots
- [x] 4.2 Fix any divergences; do NOT proceed to cleanup or cutover until all fixtures pass
- [x] 4.3 Only after fixture parity is confirmed: clean up any dead code, simplify, refactor
## 5. Cut over marketplace to cc-os source (LAST functional task)
- [x] 5.1 Verify the exact mechanism to repoint the `local-plugins` marketplace (source-path change vs symlink vs re-registration) — do not assume; confirm against current Claude Code plugin system behavior
- [x] 5.2 Repoint `local-plugins` marketplace entry for `memory` to `cc-os/plugins/memory/`
- [x] 5.3 Update `~/.claude/settings.json` hook entries to invoke the Python scripts
- [x] 5.4 Start a new Claude Code session; confirm memory plugin loads from cc-os path and all hooks fire correctly
## 6. Documentation and ADR
- [x] 6.1 Add ADR-016 to `docs/memory-system/03-architecture-decisions.md` recording the three decisions: source location, module shape (deep functions vs OO hierarchy), and memsearch-sync split (relocation not reversal of ADR-015)
- [x] 6.2 Update `CLAUDE.md` Implemented Components section: plugin source pointer changed to `cc-os/plugins/memory/`; update hook language from bash to Python
- [x] 6.3 Mark this change done in `docs/memory-system/04-build-plan.md` and update build plan status

View File

@ -0,0 +1,50 @@
# Spec: Hook Python Port
## Purpose
Defines the requirements for porting the memory plugin's bash hooks to Python, including shared module structure, error handling conventions, and correctness verification via golden fixtures.
## Requirements
### Requirement: Single config loader
A `config.py` module SHALL expose `load_config() -> Config` (frozen dataclass) that parses `config.yaml`, expands paths, applies defaults, and handles missing file — replacing all duplicated inline YAML parsers.
#### Scenario: Config loaded once per hook invocation
- **WHEN** any hook script runs
- **THEN** it calls `load_config()` exactly once and accesses all config values through the returned `Config` dataclass
### Requirement: Uniform stdin parser
A `hook_io.py` module SHALL expose `read_input() -> HookInput` (dataclass with fields: session_id, cwd, file_path, reason) that parses hook stdin JSON, replacing all inline stdin parsers and eliminating the `python3`-vs-`jq` inconsistency.
#### Scenario: stdin parsed uniformly across all hooks
- **WHEN** any of the five hook entry points (session_start, session_context, post_tool_use_write, session_end, memsearch_sync) runs
- **THEN** stdin is parsed via `read_input()`, not inline shell commands or ad-hoc python
### Requirement: Named session-state contract
A `session_state.py` module SHALL expose `record_touch(session_id, path)` and `read_touches(session_id) -> list[str]`, encapsulating the `/tmp/claude-vault-touched-$SESSION_ID` temp-file handoff between post-tool-use-write and session-end hooks.
#### Scenario: Touch handoff uses named functions
- **WHEN** post_tool_use_write records a vault touch
- **THEN** it calls `record_touch(session_id, path)` rather than writing the temp file directly
#### Scenario: Session-end reads touches via named function
- **WHEN** session_end reads which vault files were touched
- **THEN** it calls `read_touches(session_id)` rather than reading the temp file directly
### Requirement: Fail-open hook mains
Every hook `main()` function MUST wrap execution in try/except; uncaught exceptions MUST log the error and exit with code 0.
#### Scenario: Exception does not disrupt session
- **WHEN** a hook raises an uncaught exception
- **THEN** the hook logs the exception to stderr and exits 0 (session continues normally)
### Requirement: Behavior-faithful port with golden fixtures
The Python port MUST demonstrate identical output to the bash hooks against golden fixtures before any cleanup or refactoring.
#### Scenario: Golden fixtures captured before port
- **WHEN** the port task begins
- **THEN** golden fixture files exist for each of the 4 bash hooks (known stdin → snapshot of side effects)
#### Scenario: Python output matches bash output
- **WHEN** Python hooks are run against golden fixtures
- **THEN** output matches the bash hook snapshots exactly before any refactor proceeds

View File

@ -73,7 +73,7 @@ The plugin SHALL register a SessionEnd hook that appends a dated entry to the da
- **THEN** the hook creates the file with appropriate frontmatter (summary, scope/global, type/log) and appends the session entry
### Requirement: Plugin configuration block
The plugin SHALL expose a configuration file at `~/.claude/plugins/memory/config.yaml` that allows setting: vault path, Graphify output directory, Ollama model name (Modelfile-baked variant), num_ctx, rebuild-stale threshold in days, and the env vars `OLLAMA_FLASH_ATTENTION`, `GRAPHIFY_OLLAMA_NUM_CTX`, `GRAPHIFY_OLLAMA_KEEP_ALIVE`. All hooks SHALL read from this config rather than hardcoding paths or values.
The plugin SHALL expose a configuration file at `cc-os/plugins/memory/config.yaml` that allows setting: vault path, Graphify output directory, Ollama model name (Modelfile-baked variant), num_ctx, rebuild-stale threshold in days, and the env vars `OLLAMA_FLASH_ATTENTION`, `GRAPHIFY_OLLAMA_NUM_CTX`, `GRAPHIFY_OLLAMA_KEEP_ALIVE`. All hooks SHALL read from this config rather than hardcoding paths or values.
#### Scenario: Default config is used
- **WHEN** the config file exists with no overrides for a setting

View File

@ -0,0 +1,25 @@
# Spec: Memsearch Sync
## Purpose
Defines the requirements for the memsearch git-sync hook entry point, including separation of concerns from the session-end vault-journal hook and preservation of ADR-015 sync behavior.
## Requirements
### Requirement: memsearch git-sync in dedicated hook entry
The memsearch git-sync operation SHALL execute from its own hook entry point (`memsearch_sync.py`) rather than from `session_end.py`.
#### Scenario: session-end handles vault journal only
- **WHEN** a session ends
- **THEN** `session_end.py` handles only vault journal writes; it does NOT perform any memsearch git operations
#### Scenario: memsearch-sync hook handles git operations
- **WHEN** a session ends
- **THEN** `memsearch_sync.py` runs as a separate SessionEnd hook entry and performs the memsearch git commit+push
### Requirement: ADR-015 behavior preserved
The memsearch git-sync MUST continue to auto-commit and push to `forgejo.swansoncloud.com/jared/memsearch` on session end, exactly as specified by ADR-015. This split is a relocation of which file performs the sync, not a change to sync behavior or destination.
#### Scenario: Sync still fires on session end
- **WHEN** a session ends after cutover
- **THEN** memsearch daily memory files are committed and pushed to the Forgejo repo, identical to pre-cutover behavior

View File

@ -0,0 +1,28 @@
# Spec: Plugin Source
## Purpose
Defines the requirements for moving the memory plugin's authoritative source into the cc-os repository under git version control, and updating the Claude Code marketplace to load from that path.
## Requirements
### Requirement: Plugin source in cc-os repo
Plugin source SHALL live at `cc-os/plugins/memory/` under git version control.
#### Scenario: Source is tracked in git
- **WHEN** a developer inspects the cc-os repository
- **THEN** `plugins/memory/` directory exists with all hook scripts and plugin config under git tracking
### Requirement: Marketplace points to cc-os source
The `local-plugins` marketplace entry for `memory` SHALL reference `cc-os/plugins/memory/` as its source path.
#### Scenario: Plugin loads from cc-os path
- **WHEN** Claude Code starts a session after cutover
- **THEN** the memory plugin is loaded from `cc-os/plugins/memory/` (not `~/.claude/plugins/memory/`)
### Requirement: No unversioned in-place edits
Plugin files at `~/.claude/plugins/memory/` SHALL NOT be the authoritative source after cutover.
#### Scenario: Edit is versioned
- **WHEN** a developer modifies a hook script
- **THEN** the change is made in `cc-os/plugins/memory/`, committed to git, and takes effect on next session load

View File

@ -2,7 +2,7 @@ _Last updated: 2026-06-08_
## Scope
This spec covers the refactored memory skill layout: `memory-vault` (renamed from `memory-query`), `memory-project` (new), the routing rule in `~/.claude/CLAUDE.md`, and the trimmed `session-context.sh`. The original spec name (project-graph-lifecycle) is retained for path stability.
This spec covers the refactored memory skill layout: `memory-vault` (renamed from `memory-query`), `memory-project` (new), the routing rule in `~/.claude/CLAUDE.md`, and the trimmed `session_context.py`. The original spec name (project-graph-lifecycle) is retained for path stability.
---
@ -78,7 +78,7 @@ The `memory-project` SKILL.md SHALL:
- Delegate all query mechanics (traversal, --budget, --dfs, graphify path, graphify explain) to `memory-vault` — do not duplicate
3.5 Cover **graph path discovery**:
- `session-context.sh` injects the graph path when `graphify-out/graph.json` exists
- `session_context.py` injects the graph path when `graphify-out/graph.json` exists
- If no graph path is injected and Claude is in a project context, suggest running the onboard sequence before proceeding with knowledge queries
3.6 State hard constraints:
@ -101,29 +101,29 @@ The `memory-project` SKILL.md SHALL:
- **THEN** Claude runs `graphify update . --force` to clear ghost nodes
#### Scenario: Claude starts a session in a project with no graph
- **WHEN** no graph path is injected by session-context.sh
- **WHEN** no graph path is injected by session_context.py
- **AND** Claude is working in a project context
- **THEN** Claude suggests the onboard sequence before knowledge queries
- **AND** Claude does NOT silently fall back to vault-only retrieval without noting the gap
---
## Requirement 4: session-context.sh trimmed to project graph pointer
## Requirement 4: session_context.py trimmed to project graph pointer
`session-context.sh` SHALL emit only:
`session_context.py` SHALL emit only:
- The absolute path to `<project-root>/graphify-out/graph.json` — if and only if that file exists
`session-context.sh` SHALL NOT emit:
`session_context.py` SHALL NOT emit:
- Vault graph reports
- Live convention queries
- Journal pointers
#### Scenario: session-context.sh runs in an onboarded project
#### Scenario: session_context.py runs in an onboarded project
- **WHEN** `<project-root>/graphify-out/graph.json` exists
- **THEN** the script outputs the absolute path to the graph file
- **AND** no other memory context is emitted
#### Scenario: session-context.sh runs in a project with no graph
#### Scenario: session_context.py runs in a project with no graph
- **WHEN** `<project-root>/graphify-out/graph.json` does not exist
- **THEN** the script outputs nothing (or a minimal "no project graph" note)
@ -137,7 +137,7 @@ The `memory-project` SKILL.md SHALL:
5.3 Same renames and additions SHALL be applied to `.pi/skills/`.
5.4 All three directories (`~/.claude/plugins/memory/skills/`, `.codex/skills/`, `.pi/skills/`) SHALL remain content-identical after this change.
5.4 All three directories (`cc-os/plugins/memory/skills/`, `.codex/skills/`, `.pi/skills/`) SHALL remain content-identical after this change.
#### Scenario: Verifying mirror consistency
- **WHEN** any of the three skill directories is read

View File

@ -0,0 +1,5 @@
{
"name": "memory",
"description": "Vault and graph memory system for Claude Code — integrates SecondBrain knowledge graph, episodic journal, and convention summaries into every session",
"version": "0.1.0"
}

5
plugins/memory/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Generated / rebuildable outputs
graphify-out/
__pycache__/
*.pyc
*.log

46
plugins/memory/README.md Normal file
View File

@ -0,0 +1,46 @@
# Memory Plugin for Claude Code
Global Claude Code plugin that integrates the SecondBrain vault and Graphify knowledge graph into Claude sessions.
## Requirements
- Graphify **v0.8.31** (pinned — do not upgrade without verifying the patch)
- Ollama with the `qwen25-coder-7b-16k` Modelfile-baked model
- `jq` available on PATH
## Critical: reasoning_effort patch
Graphify v0.8.31 requires a `reasoning_effort:"none"` patch to prevent hanging on Ollama extraction. After any `pip upgrade graphifyy`, verify the patch is still present:
```bash
python -c "import graphify; import inspect; src = inspect.getsource(graphify); print('patch present' if 'reasoning_effort' in src else 'PATCH MISSING — reinstall v0.8.31')"
```
If the patch is missing, reinstall the pinned version:
```bash
pip install graphifyy==0.8.31
```
## Modelfile-baked context
The `num_ctx` setting (8192) is baked into the `qwen25-coder-7b-16k` Ollama Modelfile rather than passed as an env var, because `GRAPHIFY_OLLAMA_NUM_CTX` propagation is unreliable. If you create a new Modelfile variant, bake `num_ctx` in directly.
## Hooks
- `hooks/session_start.py` — SessionStart: staleness check, detached rebuild, vault context injection
- `hooks/session_context.py` — UserPromptSubmit: project graph path injection
- `hooks/post_tool_use_write.py` — PostToolUse: graph update on vault writes
- `hooks/session_end.py` — SessionEnd: episodic journal append
- `hooks/memsearch_sync.py` — SessionEnd: memsearch auto-commit+push (ADR-015 behavior, split from session_end by ADR-016)
Shared modules: `hooks/config.py`, `hooks/hook_io.py`, `hooks/session_state.py`.
## Skills
- `skills/memory-query.md` — When and how to query the vault graph
- `skills/memory-write.md` — When and how to write to the vault
- `skills/memory-reorganize.md` — Plan-mode vault consolidation
## Configuration
Edit `cc-os/plugins/memory/config.yaml` (or via the symlink `~/.claude/plugins/memory/config.yaml`) to change vault path, model, or thresholds.

View File

@ -0,0 +1,10 @@
# Memory plugin configuration
vault_path: ~/Documents/SecondBrain
graphify_output_dir: ~/Documents/SecondBrain/graphify-out
ollama_model: qwen25-coder-7b-16k
num_ctx: 8192
stale_threshold_days: 7
env:
OLLAMA_FLASH_ATTENTION: "1"
GRAPHIFY_OLLAMA_NUM_CTX: "8192"
GRAPHIFY_OLLAMA_KEEP_ALIVE: "5"

View File

@ -0,0 +1,66 @@
"""Config loading for the memory plugin hooks (deep module).
Replaces the duplicated inline YAML parsers in the four bash hooks with a single
load_config() call returning a frozen Config dataclass. Fail-open: a missing or
malformed config file yields defaults, never an exception.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
try:
import yaml
except Exception: # pragma: no cover - yaml is provided via graphify
yaml = None
CONFIG_PATH = os.path.expanduser("~/.claude/plugins/memory/config.yaml")
DEFAULT_VAULT_PATH = "~/Documents/SecondBrain"
DEFAULT_GRAPHIFY_OUTPUT_DIR = "~/Documents/SecondBrain/.graphify"
DEFAULT_MODEL = "qwen25-coder-7b-16k"
DEFAULT_STALE_DAYS = 7
DEFAULT_MEMSEARCH_DIR = "~/.memsearch"
@dataclass(frozen=True)
class Config:
vault_path: str
graphify_output_dir: str
model: str
stale_days: int
memsearch_dir: str
env: dict = field(default_factory=dict)
def load_config(path: str = CONFIG_PATH) -> "Config":
cfg = {}
if yaml is not None:
try:
with open(os.path.expanduser(path)) as f:
cfg = yaml.safe_load(f) or {}
except Exception:
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
def expand(value):
return os.path.expanduser(str(value))
try:
stale_days = int(cfg.get("stale_threshold_days", DEFAULT_STALE_DAYS))
except Exception:
stale_days = DEFAULT_STALE_DAYS
env_block = cfg.get("env") or {}
if not isinstance(env_block, dict):
env_block = {}
return Config(
vault_path=expand(cfg.get("vault_path", DEFAULT_VAULT_PATH)),
graphify_output_dir=expand(cfg.get("graphify_output_dir", DEFAULT_GRAPHIFY_OUTPUT_DIR)),
model=str(cfg.get("ollama_model", DEFAULT_MODEL)),
stale_days=stale_days,
memsearch_dir=expand(cfg.get("memsearch_dir", DEFAULT_MEMSEARCH_DIR)),
env={str(k): str(v) for k, v in env_block.items()},
)

View File

@ -0,0 +1,47 @@
"""Uniform stdin parsing for the memory plugin hooks (deep module).
Replaces four inline stdin parsers and the python3-vs-jq inconsistency with a
single read_input() returning a HookInput dataclass. Fail-open: malformed or
empty stdin yields empty-string fields, never an exception. Per-hook fallback
semantics (e.g. 'unknown' defaults, PID fallback) are applied by each hook, not
here, to stay faithful to the original bash behavior.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
@dataclass(frozen=True)
class HookInput:
session_id: str
cwd: str
file_path: str
reason: str
source: str
raw: dict
def read_input() -> "HookInput":
data = {}
try:
raw = sys.stdin.read()
data = json.loads(raw) if raw.strip() else {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
tool_input = data.get("tool_input") or {}
if not isinstance(tool_input, dict):
tool_input = {}
return HookInput(
session_id=str(data.get("session_id", "") or ""),
cwd=str(data.get("cwd", "") or ""),
file_path=str(tool_input.get("file_path", "") or ""),
reason=str(data.get("reason", "") or ""),
source=str(data.get("source", "") or ""),
raw=data,
)

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""SessionEnd hook (split out) — auto-commit + push the memsearch memory store.
Preserves ADR-015 behavior: commit memory/*.md in the dedicated memsearch repo and
push to forgejo.swansoncloud.com/jared/memsearch. The memsearch directory is now a
Config field (memsearch_dir, default ~/.memsearch) instead of a hardcoded absolute
path. Fail-open; must never disrupt session shutdown.
"""
import os
import sys
import subprocess
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config as config_mod
def main():
cfg = config_mod.load_config()
memsearch_dir = cfg.memsearch_dir
if not os.path.isdir(os.path.join(memsearch_dir, ".git")):
return
devnull = subprocess.DEVNULL
subprocess.run(["git", "-C", memsearch_dir, "add", "-A"], stdout=devnull, stderr=devnull)
staged = subprocess.run(
["git", "-C", memsearch_dir, "diff", "--cached", "--quiet"],
stdout=devnull, stderr=devnull,
)
if staged.returncode != 0:
date_str = datetime.now().strftime("%Y-%m-%d")
subprocess.run(
["git", "-C", memsearch_dir, "commit", "-m", f"memsearch: session memory {date_str}"],
stdout=devnull, stderr=devnull,
)
print("[session-end] memsearch memory committed")
subprocess.run(
["timeout", "30", "git", "-C", memsearch_dir, "push", "--quiet", "origin", "main"],
stdout=devnull, stderr=devnull,
)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[memsearch-sync] error: {e}", file=sys.stderr)
sys.exit(0)

View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# post-tool-use-write.sh — PostToolUse hook for Write/Edit tool calls
# Fires after every Write or Edit; updates the Graphify graph if the file
# is a .md file inside the configured vault.
# Ensure log directory exists
mkdir -p ~/.cache/graphify
# Parse config with python3+PyYAML; handles missing file, missing key, and tilde
VAULT_PATH=$(python3 -c '
import yaml, os
p = os.path.expanduser("~/.claude/plugins/memory/config.yaml")
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
print(os.path.expanduser(cfg.get("vault_path", "~/Documents/SecondBrain")))
')
# Capture stdin
STDIN=$(cat)
# Extract fields
FILE_PATH=$(echo "$STDIN" | jq -r '.tool_input.file_path // empty')
SESSION_ID=$(echo "$STDIN" | jq -r '.session_id // empty')
# Guard: exit silently if FILE_PATH is empty
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# Guard: exit silently if file does not end in .md
if [[ "$FILE_PATH" != *.md ]]; then
exit 0
fi
# Guard: exit silently if file is not under VAULT_PATH
if [[ "$FILE_PATH" != "$VAULT_PATH"* ]]; then
exit 0
fi
# Record vault write and invalidate rebuild stamp
LOG="$HOME/.cache/graphify/post-tool-use.log"
# Always record touched path for session-end reconciliation
TOUCH_FILE="/tmp/claude-vault-touched-$SESSION_ID"
echo "$FILE_PATH" >> "$TOUCH_FILE"
# Invalidate the rebuild stamp so next SessionStart triggers an incremental --update rebuild
STAMP_FILE="$HOME/.cache/graphify/vault-rebuild.stamp"
if rm -f "$STAMP_FILE"; then
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Invalidated rebuild stamp: $FILE_PATH" >> "$LOG"
else
# Deletion failed (shouldn't happen), but never block
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] WARN: Could not delete rebuild stamp for $FILE_PATH" >> "$LOG"
fi
exit 0

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""PostToolUse hook for Write/Edit — record vault .md touch, invalidate rebuild stamp.
Fail-open. Exits silently (no side effects) unless the written file is a .md file
under the configured vault path.
"""
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config as config_mod
import hook_io
import session_state
def _utc():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def main():
cache_dir = os.path.expanduser("~/.cache/graphify")
try:
os.makedirs(cache_dir, exist_ok=True)
except Exception:
pass
cfg = config_mod.load_config()
inp = hook_io.read_input()
file_path = inp.file_path
session_id = inp.session_id
if not file_path:
return
if not file_path.endswith(".md"):
return
if not file_path.startswith(cfg.vault_path):
return
log = os.path.join(cache_dir, "post-tool-use.log")
session_state.record_touch(session_id, file_path)
stamp = os.path.join(cache_dir, "vault-rebuild.stamp")
removed_ok = True
try:
os.remove(stamp)
except FileNotFoundError:
pass
except Exception:
removed_ok = False
try:
with open(log, "a") as f:
if removed_ok:
f.write(f"[{_utc()}] Invalidated rebuild stamp: {file_path}\n")
else:
f.write(f"[{_utc()}] WARN: Could not delete rebuild stamp for {file_path}\n")
except Exception:
pass
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[post-tool-use-write] error: {e}", file=sys.stderr)
sys.exit(0)

View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# UserPromptSubmit hook — injects project graph path on the FIRST user prompt per session.
# Outputs: {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "..."}}
# On subsequent prompts: outputs {} (no-op).
# Do NOT set -e — failures are expected and handled.
# ---------------------------------------------------------------------------
# 0. Read stdin
# ---------------------------------------------------------------------------
STDIN_JSON="$(cat 2>/dev/null || true)"
SESSION_ID="$(printf '%s' "$STDIN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('session_id','unknown'))" 2>/dev/null || true)"
CWD="$(printf '%s' "$STDIN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || true)"
CWD="${CWD:-$PWD}"
FLAG_FILE="/tmp/memory-context-injected-${SESSION_ID:-unknown}"
if [ -f "$FLAG_FILE" ]; then
echo '{}'
exit 0
fi
touch "$FLAG_FILE"
# ---------------------------------------------------------------------------
# 1. Project graph pointer (only output)
# ---------------------------------------------------------------------------
CONTEXT=""
PROJECT_ROOT="$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$PROJECT_ROOT" ]; then
PROJECT_GRAPH="$PROJECT_ROOT/graphify-out/graph.json"
if [ -f "$PROJECT_GRAPH" ]; then
CONTEXT="## Project Graph
Project graph: ${PROJECT_GRAPH}
"
fi
fi
# ---------------------------------------------------------------------------
# 2. Emit JSON
# ---------------------------------------------------------------------------
OUTPUT="$(python3 -c "import json, sys; print(json.dumps({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit', 'additionalContext': sys.argv[1]}}))" "$CONTEXT" 2>/dev/null)"
printf '%s\n' "$OUTPUT"
exit 0

View File

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# session-end.sh — SessionEnd hook
# Fires at session end; appends a journal entry to the vault's daily log.
# Exit code is ignored by Claude Code — used for cleanup/logging only.
# Capture stdin
STDIN=$(cat)
# Extract fields
SESSION_ID=$(echo "$STDIN" | jq -r '.session_id // empty')
REASON=$(echo "$STDIN" | jq -r '.reason // empty')
# Guard: if SESSION_ID is empty, use PID as fallback to ensure unique path
if [ -z "$SESSION_ID" ]; then
SESSION_ID="unknown-$$"
fi
# Parse config with python3+PyYAML; handles missing file, missing key, and tilde
VAULT_PATH=$(python3 -c '
import yaml, os
p = os.path.expanduser("~/.claude/plugins/memory/config.yaml")
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
print(os.path.expanduser(cfg.get("vault_path", "~/Documents/SecondBrain")))
')
# Compute journal path
JOURNAL_DIR="$VAULT_PATH/journal"
JOURNAL_PATH="$JOURNAL_DIR/$(date -u +%Y-%m-%d).md"
# Ensure journal directory exists
mkdir -p "$JOURNAL_DIR"
# Create journal note if absent (minimal frontmatter)
if [[ ! -f "$JOURNAL_PATH" ]]; then
cat >"$JOURNAL_PATH" <<'EOF'
---
summary: Daily session log
tags: [scope/global, type/log]
---
EOF
fi
# Read touched paths from temp file
TOUCH_FILE="/tmp/claude-vault-touched-$SESSION_ID"
TOUCHED=$(cat "$TOUCH_FILE" 2>/dev/null || echo "")
# Format touched paths list
if [[ -z "$TOUCHED" ]]; then
TOUCHED_LIST="(none)"
else
TOUCHED_LIST="$TOUCHED"
fi
# Determine project context
PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-unknown}")
# UTC timestamp for entry header
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Append journal entry
{
echo ""
echo "## Session — $TIMESTAMP"
echo ""
echo "**Project:** $PROJECT"
echo "**Reason:** $REASON"
echo "**Vault notes touched:**"
echo "$TOUCHED_LIST"
} >> "$JOURNAL_PATH"
# Cleanup temp file
rm -f "$TOUCH_FILE"
# ── memsearch memory git sync (fail-safe) ───────────────────────────────────
# Auto-commit + push the memsearch memory store (~/.memsearch) on session end.
# We own this sync here so we never patch the marketplace memsearch plugin
# (which clobbers on update). The whitelist .gitignore in ~/.memsearch tracks
# only memory/*.md + .gitignore, so `add -A` is safe.
#
# Robustness contract: this block MUST NEVER fail session shutdown.
# - Skips silently if ~/.memsearch is not a git repo.
# - Only commits when something is staged (no empty commits).
# - Push is hard-timeboxed and failures are swallowed; the next session's
# hook catches up since the daily memory files are append-only.
# Wrapped in a subshell with `|| true` so nothing here can abort the hook.
(
MEMSEARCH_DIR="/home/jared/.memsearch"
if [ -d "$MEMSEARCH_DIR/.git" ]; then
git -C "$MEMSEARCH_DIR" add -A >/dev/null 2>&1
if ! git -C "$MEMSEARCH_DIR" diff --cached --quiet; then
git -C "$MEMSEARCH_DIR" commit -m "memsearch: session memory $(date +%F)" >/dev/null 2>&1
echo "[session-end] memsearch memory committed"
fi
timeout 30 git -C "$MEMSEARCH_DIR" push --quiet origin main >/dev/null 2>&1 || true
fi
) || true
# ────────────────────────────────────────────────────────────────────────────
exit 0

View File

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# SessionStart hook for the memory plugin.
# Invoked by Claude Code at the start of every session with JSON on stdin.
# Must return sub-second; heavy work is detached in the background.
# Context injection is handled by session-context.sh (UserPromptSubmit hook).
# Never let any individual failure exit the whole script.
# Do NOT set -e — grep-no-match, graphify errors, etc. are all expected.
# ---------------------------------------------------------------------------
# 0. Read stdin (ignore errors — fields may be absent on older Claude Code)
# ---------------------------------------------------------------------------
STDIN_JSON="$(cat 2>/dev/null || true)"
HOOK_SOURCE="$(printf '%s' "$STDIN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('source','unknown'))" 2>/dev/null || true)"
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] session-start fired: source=${HOOK_SOURCE:-unknown} cwd=$(pwd)" >> /tmp/memory-hook.log 2>/dev/null || true
CWD="$(printf '%s' "$STDIN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || true)"
CWD="${CWD:-$PWD}"
# ---------------------------------------------------------------------------
# 1. Parse config.yaml — emit shell variable assignments, eval them in.
# python3 + PyYAML are guaranteed present via graphify.
# ---------------------------------------------------------------------------
eval "$(python3 - <<'PY'
import yaml, os, shlex, sys
CONFIG_PATH = os.path.expanduser("~/.claude/plugins/memory/config.yaml")
try:
with open(CONFIG_PATH) as f:
cfg = yaml.safe_load(f) or {}
except Exception:
cfg = {}
out = {
"VAULT_PATH": os.path.expanduser(cfg.get("vault_path", "~/Documents/SecondBrain")),
"GRAPHIFY_OUTPUT_DIR": os.path.expanduser(cfg.get("graphify_output_dir", "~/Documents/SecondBrain/.graphify")),
"MODEL": cfg.get("ollama_model", "qwen25-coder-7b-16k"),
"STALE_DAYS": str(cfg.get("stale_threshold_days", 7)),
}
for k, v in out.items():
print(f"{k}={shlex.quote(str(v))}")
# Export env block so the background rebuild process inherits them.
for k, v in (cfg.get("env") or {}).items():
print(f"export {k}={shlex.quote(str(v))}")
PY
2>/dev/null || true)"
# Apply fallbacks in case the python3 block produced nothing.
VAULT_PATH="${VAULT_PATH:-$HOME/Documents/SecondBrain}"
GRAPHIFY_OUTPUT_DIR="${GRAPHIFY_OUTPUT_DIR:-$HOME/Documents/SecondBrain/.graphify}"
MODEL="${MODEL:-qwen25-coder-7b-16k}"
STALE_DAYS="${STALE_DAYS:-7}"
# ---------------------------------------------------------------------------
# 2. Cache dir + file paths
# ---------------------------------------------------------------------------
CACHE_DIR="$HOME/.cache/graphify"
STAMP="$CACHE_DIR/vault-rebuild.stamp"
LOCK="$CACHE_DIR/vault-rebuild.lock"
LOGFILE="$CACHE_DIR/vault-rebuild.log"
mkdir -p "$CACHE_DIR" 2>/dev/null || true
# Export vars needed by the detached bash -c subprocess.
export VAULT_PATH MODEL STAMP LOCK LOGFILE
# ---------------------------------------------------------------------------
# 3. Staleness check + detached background rebuild
# ---------------------------------------------------------------------------
_needs_rebuild() {
[ ! -f "$STAMP" ] && return 0
# Use python3 for portable mtime arithmetic (avoids `date -d` vs BSD split).
python3 - <<PY 2>/dev/null
import os, time
stamp = "$STAMP"
stale_days = int("$STALE_DAYS")
try:
age_days = (time.time() - os.path.getmtime(stamp)) / 86400
exit(0 if age_days > stale_days else 1)
except Exception:
exit(0)
PY
}
if _needs_rebuild; then
if [ -f "$LOCK" ]; then
echo "[memory/session-start] rebuild already running (lock present), skipping" >> "$LOGFILE" 2>/dev/null || true
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] session-start: rebuild skipped (lock present)" >> /tmp/memory-hook.log 2>/dev/null || true
else
# Create lock, then spawn fully detached (no inherited stdin/stdout/stderr).
touch "$LOCK" 2>/dev/null || true
# shellcheck disable=SC2016
nohup bash -c \
'graphify extract "$VAULT_PATH" --backend ollama --model "$MODEL" --force \
>> "$LOGFILE" 2>&1 \
&& date > "$STAMP" && rm -f "$LOCK" \
|| (rm -f "$LOCK"; echo "rebuild failed" >> "$LOGFILE")' \
</dev/null >/dev/null 2>&1 &
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] session-start: rebuild triggered" >> /tmp/memory-hook.log 2>/dev/null || true
fi
else
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] session-start: rebuild not needed" >> /tmp/memory-hook.log 2>/dev/null || true
fi
exit 0

View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""UserPromptSubmit hook — inject project graph path on the first prompt. Fail-open.
First prompt per session: emits hookSpecificOutput with additionalContext.
Subsequent prompts: emits {} (no-op).
"""
import os
import sys
import json
import subprocess
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import hook_io
def main():
inp = hook_io.read_input()
session_id = inp.session_id or "unknown"
cwd = inp.cwd or os.getcwd()
flag_file = f"/tmp/memory-context-injected-{session_id}"
if os.path.exists(flag_file):
print("{}")
return
try:
open(flag_file, "a").close()
except Exception:
pass
context = ""
try:
r = subprocess.run(
["git", "-C", cwd, "rev-parse", "--show-toplevel"],
capture_output=True, text=True,
)
project_root = r.stdout.strip() if r.returncode == 0 else ""
except Exception:
project_root = ""
if project_root:
project_graph = os.path.join(project_root, "graphify-out", "graph.json")
if os.path.isfile(project_graph):
context = "## Project Graph\n\nProject graph: %s\n" % project_graph
output = json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
}
})
print(output)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[session-context] error: {e}", file=sys.stderr)
sys.exit(0)

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""SessionEnd hook — append a journal entry to the vault's daily log. Fail-open.
The memsearch git sync is split into memsearch_sync.py (its own SessionEnd entry).
"""
import os
import sys
import subprocess
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config as config_mod
import hook_io
import session_state
JOURNAL_TEMPLATE = """---
summary: Daily session log
tags: [scope/global, type/log]
---
"""
def main():
inp = hook_io.read_input()
session_id = inp.session_id
reason = inp.reason
if not session_id:
session_id = f"unknown-{os.getpid()}"
cfg = config_mod.load_config()
journal_dir = os.path.join(cfg.vault_path, "journal")
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
journal_path = os.path.join(journal_dir, f"{date_str}.md")
try:
os.makedirs(journal_dir, exist_ok=True)
except Exception:
pass
if not os.path.exists(journal_path):
try:
with open(journal_path, "w") as f:
f.write(JOURNAL_TEMPLATE)
except Exception:
pass
touched = session_state.read_touches(session_id)
touched_list = "\n".join(touched) if touched else "(none)"
try:
r = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True,
)
if r.returncode == 0 and r.stdout.strip():
project = r.stdout.strip()
else:
project = os.environ.get("CLAUDE_PROJECT_DIR") or "unknown"
except Exception:
project = os.environ.get("CLAUDE_PROJECT_DIR") or "unknown"
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
entry = (
"\n"
f"## Session — {timestamp}\n"
"\n"
f"**Project:** {project}\n"
f"**Reason:** {reason}\n"
"**Vault notes touched:**\n"
f"{touched_list}\n"
)
try:
with open(journal_path, "a") as f:
f.write(entry)
except Exception:
pass
session_state.clear_touches(session_id)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[session-end] error: {e}", file=sys.stderr)
sys.exit(0)

View File

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""SessionStart hook — staleness check + detached graph rebuild. Fail-open.
Must return sub-second; heavy work is detached in the background. Context
injection is handled by session_context.py (UserPromptSubmit hook).
"""
import os
import sys
import time
import subprocess
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config as config_mod
import hook_io
HOOK_LOG = "/tmp/memory-hook.log"
def _utc():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _log(line):
try:
with open(HOOK_LOG, "a") as f:
f.write(line + "\n")
except Exception:
pass
def _needs_rebuild(stamp, stale_days):
if not os.path.exists(stamp):
return True
try:
age_days = (time.time() - os.path.getmtime(stamp)) / 86400
return age_days > stale_days
except Exception:
return True
def main():
inp = hook_io.read_input()
source = inp.source or "unknown"
_log(f"[{_utc()}] session-start fired: source={source} cwd={os.getcwd()}")
cfg = config_mod.load_config()
cache_dir = os.path.expanduser("~/.cache/graphify")
stamp = os.path.join(cache_dir, "vault-rebuild.stamp")
lock = os.path.join(cache_dir, "vault-rebuild.lock")
logfile = os.path.join(cache_dir, "vault-rebuild.log")
try:
os.makedirs(cache_dir, exist_ok=True)
except Exception:
pass
if _needs_rebuild(stamp, cfg.stale_days):
if os.path.exists(lock):
try:
with open(logfile, "a") as f:
f.write("[memory/session-start] rebuild already running (lock present), skipping\n")
except Exception:
pass
_log(f"[{_utc()}] session-start: rebuild skipped (lock present)")
else:
try:
open(lock, "a").close()
except Exception:
pass
env = dict(os.environ)
env.update(cfg.env)
env["VAULT_PATH"] = cfg.vault_path
env["MODEL"] = cfg.model
env["STAMP"] = stamp
env["LOCK"] = lock
env["LOGFILE"] = logfile
cmd = (
'graphify extract "$VAULT_PATH" --backend ollama --model "$MODEL" --force '
'>> "$LOGFILE" 2>&1 '
'&& date > "$STAMP" && rm -f "$LOCK" '
'|| (rm -f "$LOCK"; echo "rebuild failed" >> "$LOGFILE")'
)
try:
devnull = open(os.devnull, "wb")
subprocess.Popen(
["bash", "-c", cmd],
stdin=subprocess.DEVNULL,
stdout=devnull,
stderr=devnull,
env=env,
start_new_session=True,
)
except Exception:
pass
_log(f"[{_utc()}] session-start: rebuild triggered")
else:
_log(f"[{_utc()}] session-start: rebuild not needed")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[session-start] error: {e}", file=sys.stderr)
sys.exit(0)

View File

@ -0,0 +1,38 @@
"""Touched-paths handoff between post-tool-use-write and session-end (deep module).
Encapsulates the /tmp/claude-vault-touched-<session_id> temp-file contract. The
file still exists by necessity the two hooks are separate process invocations
with no shared memory; the win is an explicit, named, testable contract.
One path per line. Fail-open throughout.
"""
from __future__ import annotations
import os
def _touch_file(session_id: str) -> str:
return f"/tmp/claude-vault-touched-{session_id}"
def record_touch(session_id: str, path: str) -> None:
try:
with open(_touch_file(session_id), "a") as f:
f.write(path + "\n")
except Exception:
pass
def read_touches(session_id: str) -> list:
try:
with open(_touch_file(session_id)) as f:
content = f.read()
except Exception:
return []
return [line for line in content.splitlines() if line]
def clear_touches(session_id: str) -> None:
try:
os.remove(_touch_file(session_id))
except Exception:
pass

View File

@ -0,0 +1,52 @@
---
description: Manage per-project Graphify knowledge graphs — onboard, update, remove, and query codebase structure for the current repo
---
**Scope:** project graph at `./graphify-out/graph.json` — codebase structure and module relationships for the current repo only. For cross-project evergreen knowledge, use `memory-vault`.
## Onboard
Run from the project root:
```bash
graphify extract . --backend ollama --model qwen2.5-coder:7b
graphify cluster-only .
```
Then ensure `graphify-out/` is in `.gitignore`. Confirm `graphify-out/graph.json` exists before reporting done.
Code-only repos use the free tree-sitter AST pass — no LLM cost. The `--backend ollama` flags are always specified so the single command handles mixed (code + docs) repos without requiring repo-type detection.
## Incremental update
```bash
# Routine edits (no renames/deletions/moves)
graphify update .
# Structural changes (renames, deletions, directory moves)
graphify update . --force
```
Suggest running `graphify update .` after sessions involving significant refactoring, and `--force` after any renames or directory reorganization.
## Remove
Delete `graphify-out/` when the project is inactive or the graph is too stale. The graph is always rebuildable via the onboard sequence. Leave the `.gitignore` entry even after removal.
## Query
```bash
graphify query "<question>" --graph ./graphify-out/graph.json
```
For traversal mechanics (`graphify path`, `graphify explain`, `--budget`, `--dfs`), see `memory-vault` — query mechanics are not duplicated here.
## Graph path discovery
`session_context.py` injects the absolute path to `graphify-out/graph.json` when it exists. If no graph path is present in session context and you are working in a project context, suggest running the onboard sequence before proceeding with knowledge queries.
## Constraints
- **Never commit `graphify-out/`** — it is local, disposable, and rebuildable.
- Model is `qwen2.5-coder:7b` — do not substitute.
- The `.gitignore` entry for `graphify-out/` must survive even if the directory is deleted.

View File

@ -0,0 +1,31 @@
---
description: Consolidate, promote, or restructure vault notes — plan mode only, requires human approval
---
**Plan mode only.** This skill proposes changes for human review before executing. Never reorganize vault structure without an approved plan.
## When to trigger
- **Duplicate coverage:** two or more notes cover the same concept with overlapping content
- **Promotion candidate:** a `scope/project` fact has recurred across 2+ clients — promote to `scope/global`
- **Oversized note:** a note has grown beyond ~200 lines or has drifted from a single concept
## Procedure
1. **Identify candidates** — list notes that meet one or more triggers above
2. **Propose a plan** — for each candidate, propose one of:
- **Merge:** combine into a single note (specify target)
- **Split:** extract distinct concepts into separate notes
- **Promote:** change `scope/project``scope/global`, broaden client tag
- **Rename:** update title/filename for clarity
3. **Await human approval** — do not execute until the plan is confirmed
4. **Execute edits** — make the approved changes
5. **Force rebuild:** after any reorganization that removes, renames, or restructures notes, run:
```bash
# vault_path and ollama_model from ~/.claude/plugins/memory/config.yaml
graphify extract <vault-path> --backend ollama --model <configured-model> --force
```
## Why --force after reorganization?
`graphify update --file` does **not** prune deleted or renamed nodes — ghost nodes accumulate. Only `--force` clears the graph completely and rebuilds clean. Always use `--force` after restructuring, not `--update`.

View File

@ -0,0 +1,41 @@
---
description: Query the SecondBrain vault knowledge graph for cross-project evergreen knowledge — conventions, tool behavior, client facts, patterns that generalize beyond one repo
---
**Scope:** vault at `~/Documents/SecondBrain` — cross-project evergreen knowledge, not codebase structure. For codebase structure and module relationships in the current repo, use `memory-project`.
Use this skill when you need to retrieve evergreen knowledge from the SecondBrain vault (conventions, tool/API behavior, client facts, cross-project patterns). For **episodic questions** ("what did we do last week", "when did X happen"), use memsearch instead.
## Query commands
```bash
# Explore a topic (start here)
graphify query "<topic>" --budget 2000
# Trace a relationship between two notes
graphify path "<note-a>" "<note-b>"
# Deep-dive a specific node
graphify explain "<node-name>"
# Bounded depth-first traversal
graphify query "<topic>" --dfs --budget 2000
```
## Tag-scoped retrieval (separate path — use with caution)
Facet-tag-based graph traversal is **not yet verified** (ADR-014 open). Whether shared facet tags (e.g. `client/acme`) create graph edges is unknown. Treat tag-based filtering as a distinct, experimental path — do not assume it works like graph traversal.
For tag-scoped queries, try:
```bash
graphify query "client/acme" --budget 2000
```
…and verify the results are actually tag-filtered before trusting them.
## Episodic questions → memsearch
For questions about sessions, events, or time-ordered history, use memsearch directly with natural language — do not use graphify for episodic retrieval.
## Writing to vault
Use `memory-write` when knowledge generalizes beyond the current repo. Routing heuristic: "Would this be useful outside this repo, next year?" If yes → vault via `memory-write`. If no → project-graph only (no persistent write needed). See `~/.claude/CLAUDE.md` for the full routing rule.

View File

@ -0,0 +1,37 @@
---
description: Write evergreen knowledge to the SecondBrain vault with correct frontmatter and scope
---
Use this skill when you have knowledge that should persist across projects and sessions.
## Evergreen vs. ephemeral test
**Write to vault if:** the knowledge is reusable across projects (tool/API behavior, client-specific facts, conventions, cross-project patterns). Vault is for knowledge that survives the current session.
**Do NOT write to vault:** project-ephemeral state (current task, in-progress decisions, temporary context). These belong in project files or the episodic layer (session journal — handled automatically by SessionEnd hook).
## Required frontmatter contract
Every vault note must have:
```yaml
---
summary: <one-line router hint write at creation, do not defer>
tags:
- scope/global # or scope/project (required)
- type/<...> # required — one of: procedure, reference, log, hub, concept, decision
- <facet-tag> # required — at least one of: client/<n>, project/<n>, domain/<n>, tool/<n>, convention/<n>
---
```
All three tag groups are required. `type/` classifies the note; the facet tag routes it to the right context.
**Write `summary` now.** It is the primary router hint for graph traversal and memsearch. Deferring it breaks retrieval.
## Vault-not-repo rule
Write ONLY to `~/Documents/SecondBrain`. Never silently write to a project repository what belongs in the vault. If in doubt, write to the vault.
## PostToolUse hook is automatic
When you write or edit a `.md` file in the vault, the PostToolUse hook records the vault write and invalidates the rebuild stamp — the graph refreshes incrementally (via `graphify extract --update`) at the next session start, not immediately. You do not need to run graphify manually.

View File

@ -0,0 +1,216 @@
# Memory Plugin Hook Tests
Golden fixture tests for the four Python hooks in `../hooks/`.
## Directory layout
```
tests/
harness.sh # Reusable per-scenario runner
generate-fixtures.sh # Runs all scenarios and writes golden/
inputs/ # Source input JSON files (with __SANDBOX__ placeholders)
golden/
<scenario>/
input.json # Input fed to hook on stdin (with __SANDBOX__ expanded)
stdout.txt # Normalized hook stdout
exit_code.txt # Hook exit code
sideeffects/ # Files and flags created by the hook (normalized)
```
## Sandbox design
Each scenario gets a fresh `SANDBOX=$(mktemp -d)` directory (path-canonicalized via `pwd -P`
to prevent symlink leaks). The harness sets `HOME="$SANDBOX"` so all hook code that resolves
`~/...` paths lands inside the sandbox, not in the real home directory.
Key sandbox contents created before each hook run:
- `$SANDBOX/.claude/plugins/memory/config.yaml` — points `vault_path` and
`graphify_output_dir` at sandbox-local dirs
- `$SANDBOX/vault/journal/` — journal target for `session_end.py`
- `$SANDBOX/.cache/graphify/` — stamp/lock/log directory for `session_start.py`
- `$SANDBOX/bin/graphify` — shim that logs calls to `graphify-shim.log`, exits 0
- `$SANDBOX/bin/git` — safety shim (see below)
`PATH="$SANDBOX/bin:$PATH"` is exported so shims take priority over system binaries.
### Git shim safety
`memsearch_sync.py` reads `MEMSEARCH_DIR` from `Config.memsearch_dir` (no longer hardcoded) — but the resolved path is still absolute and bypasses `HOME=$SANDBOX`. The git shim is the only protection against the hook committing or pushing to the real memsearch repository.
The shim at `$SANDBOX/bin/git`:
- Scans every argument for the string `/home/jared/.memsearch`
- If found: logs the blocked call to `$SANDBOX/git-shim.log` and exits 0
- If not found: `exec /usr/bin/git "$@"` (passthrough)
**Critical**: the log path is baked into the shim file at write-time (absolute literal), not
passed only via env var. This ensures the log is captured even inside session-end's
`( ... ) || true` subshell.
The shim emits **nothing** to stdout or stderr — only writes to the log file. This matters
because `memsearch_sync.py` runs `git diff --cached --quiet` without redirecting stdout, and any
shim chatter would appear in captured stdout and corrupt golden fixtures.
### Normalization rules
Applied in this order (order matters: timestamp before date-only avoids partial corruption):
1. ISO 8601 timestamps `YYYY-MM-DDTHH:MM:SSZ``__TIMESTAMP__`
2. Date-only `YYYY-MM-DD``__DATE__`
3. Sandbox temp path `$SANDBOX``__SANDBOX__`
4. `cwd=<path>` log values → `cwd=__CWD__` (varies by machine and harness invocation dir)
5. Real home path `/home/jared``__REAL_HOME__`
## Scenarios
### session-start group
| Scenario | Setup | What it tests |
|----------|-------|---------------|
| `session-start-fresh` | Stamp file exists (fresh mtime) | Rebuild NOT triggered; log says "rebuild not needed" |
| `session-start-stale` | No stamp file | Rebuild triggered via nohup; graphify shim called with vault path |
Side effects captured: `memory-hook-log-delta.txt` (lines added to `/tmp/memory-hook.log`),
`graphify-shim-args.txt` (stale only — polled up to 5 seconds for background call).
### session-context group
| Scenario | Setup | What it tests |
|----------|-------|---------------|
| `session-context-first-with-graph` | Real git repo + `graphify-out/graph.json` | First prompt injects graph path in additionalContext |
| `session-context-reentry` | Flag file pre-existing | Returns `{}` immediately (no-op on re-entry) |
| `session-context-no-graph` | Real git repo, no `graphify-out/graph.json` | Returns empty additionalContext |
Side effects captured: `flag-file-created.txt` (yes/no for all session-context scenarios).
### post-tool-use-write group
| Scenario | Setup | What it tests |
|----------|-------|---------------|
| `post-tool-use-vault-md` | Stamp exists, vault/notes dir | Touch file written, stamp deleted, log entry created |
| `post-tool-use-non-md` | vault/notes dir | Non-.md file → all guards fire, no side effects |
| `post-tool-use-outside-vault` | (none) | Outside-vault path → no side effects |
Side effects captured: `touch-file.txt`, `post-tool-use-log.txt`, `stamp-deleted.txt` (all
post-tool-use scenarios; absent/empty for non-md and outside-vault);
`noops.txt` confirmation for non-md and outside-vault.
### session-end group
| Scenario | Setup | What it tests |
|----------|-------|---------------|
| `session-end-with-touches` | Touch file with two paths | Journal written, touched paths listed, touch file deleted, memsearch git ops blocked |
| `session-end-no-touches` | No touch file | Journal written with "(none)", memsearch git ops blocked |
Side effects captured: `journal.txt` (found by glob, not date recomputation), `git-shim-log.txt`,
`touch-file-deleted.txt` (all session-end scenarios).
**Expected absence**: the shim causes `git diff --cached --quiet` to exit 0 (reads as "no staged
changes"), so `session_end.py` / `memsearch_sync.py` never reaches the commit branch. The fixture stdout therefore
does NOT contain `[session-end] memsearch memory committed`. This is correct behaviour — the
shim is protecting the real repo, not a real commit being skipped.
**Project field**: `session_end.py` runs `git rev-parse --show-toplevel` from the hook's cwd.
Scenarios run from `/tmp` with `CLAUDE_PROJECT_DIR` unset, so `**Project:** unknown` in the
journal fixture is expected.
## Running all scenarios
```bash
cd /home/jared/dev/cc-os/plugins/memory/tests
bash generate-fixtures.sh
```
The script:
1. Records the real memsearch HEAD before starting
2. Cleans `/tmp` flag files before each scenario
3. After EACH session-end scenario: immediately verifies HEAD is unchanged
4. Final check: prints SAFETY CHECK PASSED or exits non-zero
## Replaying against the Python hooks
The Python hooks are now canonical. The bash `.sh` files remain alongside them for reference but are no longer the source of truth. To validate parity (e.g. after modifying a Python hook):
1. Ensure each Python hook is executable at a known path (they live in `../hooks/`).
2. Run generate-fixtures.sh with HOOKS_DIR and --replay-dir overridden:
```bash
HOOKS_DIR=/path/to/python-hooks \
bash tests/generate-fixtures.sh --replay-dir /tmp/replay-out
```
This writes new fixtures to `/tmp/replay-out` using the Python hooks instead of bash hooks.
3. Diff against the committed golden directory:
```bash
diff -r tests/golden/ /tmp/replay-out/
```
An empty diff means the Python port is behaviorally identical. Any diff is a parity failure.
4. Clean up: `rm -rf /tmp/replay-out`
Note: `--replay-dir` skips the strict pre-run HEAD equality check (the hash may have advanced
since golden fixtures were generated). The before==after safety invariant is always enforced.
## Parity caveats and known nondeterminism
1. **session-start-stale is inherently racy.** The hook spawns graphify via `nohup ... &`
and exits immediately. The harness polls for the shim log up to 5 seconds. On heavily
loaded systems the poll may time out; if so, `graphify-shim-args.txt` will say
`(graphify not invoked within 5s)` — re-run in that case. This is a known caveat of the
bash design; the Python port should spawn the same detached pattern or document the
behavioral difference.
2. **session-start cwd in log lines is normalized to `__CWD__`.** The hook logs
`cwd=$(pwd)` (shell invocation directory, not the JSON `cwd` field). This varies by
machine and harness run location, so it is masked. The Python port should produce the
same log format with the actual cwd; the harness normalization will mask it identically.
3. **session-context stdout JSON must match exactly.** The fixture captures output of
`json.dumps(...)` with no extra whitespace. The Python port must serialize with the
same key order and no trailing newline inside the JSON value. Diff will fail on
whitespace differences even if the data is semantically identical.
4. **Journal filename is date-based (`YYYY-MM-DD.md`).** Masked to `__DATE__` in
normalized content. The harness finds the journal file by glob rather than recomputing
today's date, so it works across midnight boundaries.
5. **session-end `**Project:** unknown`** — session-end hooks run from `/tmp` with no git
repo and `CLAUDE_PROJECT_DIR` unset. The Python port should produce identical output.
## Replaying session-end scenarios safely
`memsearch_sync.py` reads the memsearch path from `Config.memsearch_dir` (not hardcoded), but the resolved path is still absolute and bypasses the sandbox. The sequence to follow:
```bash
# 1. Record HEAD before
EXPECTED=$(git -C /home/jared/.memsearch rev-parse HEAD)
echo "Expect HEAD: $EXPECTED"
# 2. Shim preflight (make_sandbox populates $SANDBOX/bin/git already)
"$SANDBOX/bin/git" -C /home/jared/.memsearch status 2>/dev/null
cat "$SANDBOX/git-shim.log" # must show BLOCKED line
# 3. Run scenario (generate-fixtures.sh does this automatically)
# 4. Verify HEAD immediately after
AFTER=$(git -C /home/jared/.memsearch rev-parse HEAD)
[ "$AFTER" = "$EXPECTED" ] && echo "PASS" || echo "BREACH: $AFTER"
```
Never run session-end scenarios with the real git binary on PATH before confirming the shim
intercepts the memsearch path.
## Adding new scenarios
1. Add input JSON to `tests/inputs/<scenario-name>.json`
- Use `__SANDBOX__` as a placeholder where the sandbox temp path is needed
2. Add a `run_<scenario_name>()` function in `generate-fixtures.sh` following the
existing pattern: `make_sandbox` → setup → expand input → run hook → `write_fixture`
capture side effects → `rm -rf "$sb"`
3. Add the call to the bottom of `generate-fixtures.sh` (before the final safety check)
4. Re-run `bash generate-fixtures.sh` to generate the golden files
5. Review the normalized output, then commit `tests/golden/<scenario-name>/` to source control
Side effect files that must be stable across machines: use `normalize()` on all paths, dates,
and timestamps before writing. If a side effect contains machine-specific data that cannot be
normalized, omit it from the golden fixture and note the omission in a `sideeffects/NOTES.txt`.

View File

@ -0,0 +1,673 @@
#!/usr/bin/env bash
# generate-fixtures.sh — Run all scenarios and write golden fixtures to tests/golden/
# Run from any directory. Uses absolute paths throughout.
# SAFETY: Verifies real memsearch HEAD is unchanged after session-end scenarios.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
# Set HOOKS_DIR to override, e.g. for Python port replay:
# HOOKS_DIR=/path/to/python-hooks bash generate-fixtures.sh --replay-dir /tmp/out
HOOKS_DIR="${HOOKS_DIR:-$(cd "$SCRIPT_DIR/../hooks" && pwd -P)}"
GOLDEN_DIR="$SCRIPT_DIR/golden"
INPUTS_DIR="$SCRIPT_DIR/inputs"
REPLAY_DIR=""
EXPECTED_HEAD="15804ed63ebf6400f22d719ee102857985f9a9d9"
# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--replay-dir) REPLAY_DIR="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# If --replay-dir is set, write there instead of golden/
if [[ -n "$REPLAY_DIR" ]]; then
mkdir -p "$REPLAY_DIR"
GOLDEN_DIR="$REPLAY_DIR"
fi
# ---------------------------------------------------------------------------
# Safety: record real memsearch HEAD
# ---------------------------------------------------------------------------
REAL_HEAD_BEFORE=$(git -C /home/jared/.memsearch rev-parse HEAD 2>/dev/null || echo "NO_REAL_REPO")
echo "=== SAFETY CHECK: real HEAD before = $REAL_HEAD_BEFORE ==="
# In replay mode, skip the strict hash check (HEAD may have advanced since fixtures were made).
# The safety invariant is before==after, not before==EXPECTED_HEAD.
if [[ -z "$REPLAY_DIR" && "$REAL_HEAD_BEFORE" != "$EXPECTED_HEAD" ]]; then
echo "ERROR: HEAD mismatch before run. Expected $EXPECTED_HEAD, got $REAL_HEAD_BEFORE" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Pre-run global cleanup of /tmp stale flags
# ---------------------------------------------------------------------------
cleanup_tmp_flags() {
rm -f /tmp/memory-context-injected-golden-sess-{003,004,005,006,006b,006c,007,008}
rm -f /tmp/claude-vault-touched-golden-sess-{006,006b,006c,007,008}
}
cleanup_tmp_flags
mkdir -p "$GOLDEN_DIR"
# ---------------------------------------------------------------------------
# Normalization helper — takes sandbox path as $1, reads stdin
# ---------------------------------------------------------------------------
normalize() {
local sandbox_path="$1"
sed \
-e "s|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T[0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}Z|__TIMESTAMP__|g" \
-e "s|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}|__DATE__|g" \
-e "s|${sandbox_path}|__SANDBOX__|g" \
-e "s|cwd=[^ ]*|cwd=__CWD__|g" \
-e "s|/home/jared|__REAL_HOME__|g"
}
# ---------------------------------------------------------------------------
# Build a fresh sandbox for a scenario
# Returns sandbox path in SANDBOX (exported)
# ---------------------------------------------------------------------------
make_sandbox() {
local scenario="$1"
local sb
sb=$(mktemp -d)
sb=$(cd "$sb" && pwd -P)
mkdir -p "$sb/.claude/plugins/memory"
cat > "$sb/.claude/plugins/memory/config.yaml" <<YAML
vault_path: ${sb}/vault
graphify_output_dir: ${sb}/vault/graphify-out
ollama_model: qwen25-coder-7b-16k
stale_threshold_days: 7
memsearch_dir: /home/jared/.memsearch
YAML
mkdir -p "$sb/vault/journal"
mkdir -p "$sb/.cache/graphify"
mkdir -p "$sb/bin"
# Graphify shim — log path baked in
cat > "$sb/bin/graphify" <<BASH
#!/usr/bin/env bash
echo "[fake-graphify] args: \$@" >> "${sb}/graphify-shim.log"
exit 0
BASH
chmod +x "$sb/bin/graphify"
# Git shim — log path BAKED IN (absolute, not env-var-only) for safety in subshells
# Emits NOTHING to stdout/stderr
cat > "$sb/bin/git" <<BASH
#!/usr/bin/env bash
for arg in "\$@"; do
if [ "\$arg" = "/home/jared/.memsearch" ]; then
echo "[git-shim] BLOCKED: git \$@" >> "${sb}/git-shim.log"
exit 0
fi
done
exec /usr/bin/git "\$@"
BASH
chmod +x "$sb/bin/git"
echo "$sb"
}
# ---------------------------------------------------------------------------
# Helper: write all fixture files for a scenario
# ---------------------------------------------------------------------------
write_fixture() {
local scenario="$1"
local sb="$2"
local stdout_content="$3"
local exit_code="$4"
local orig_input="$5"
local sdir="$GOLDEN_DIR/$scenario"
mkdir -p "$sdir/sideeffects"
echo "$stdout_content" | normalize "$sb" > "$sdir/stdout.txt"
echo "$exit_code" > "$sdir/exit_code.txt"
cp "$orig_input" "$sdir/input.json"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-start-fresh
# ---------------------------------------------------------------------------
run_session_start_fresh() {
echo ""
echo "--- session-start-fresh ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-start-fresh")
local sdir="$GOLDEN_DIR/session-start-fresh"
mkdir -p "$sdir/sideeffects"
# Setup: stamp file exists (fresh mtime) → rebuild NOT needed
mkdir -p "$sb/.cache/graphify"
touch "$sb/.cache/graphify/vault-rebuild.stamp"
# Record log offset
local log_offset=0
[[ -f /tmp/memory-hook.log ]] && log_offset=$(wc -c < /tmp/memory-hook.log)
# Expand __SANDBOX__ in input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-start-fresh.json" > "$input_expanded"
# Run hook
local stdout exit_code=0
stdout=$(HOME="$sb" PATH="$sb/bin:$PATH" GRAPHIFY_SHIM_LOG="$sb/graphify-shim.log" \
bash "$HOOKS_DIR/session-start.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Write main fixtures
write_fixture "session-start-fresh" "$sb" "$stdout" "$exit_code" "$INPUTS_DIR/session-start-fresh.json"
# Side effect: memory-hook.log delta
if [[ -f /tmp/memory-hook.log ]]; then
dd if=/tmp/memory-hook.log bs=1 skip="$log_offset" 2>/dev/null \
| normalize "$sb" > "$sdir/sideeffects/memory-hook-log-delta.txt"
else
echo "(no log entries)" > "$sdir/sideeffects/memory-hook-log-delta.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "log delta: $(cat "$sdir/sideeffects/memory-hook-log-delta.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-start-stale
# ---------------------------------------------------------------------------
run_session_start_stale() {
echo ""
echo "--- session-start-stale ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-start-stale")
local sdir="$GOLDEN_DIR/session-start-stale"
mkdir -p "$sdir/sideeffects"
# Setup: NO stamp file → rebuild needed
# (we do NOT create vault-rebuild.stamp)
# Record log offset
local log_offset=0
[[ -f /tmp/memory-hook.log ]] && log_offset=$(wc -c < /tmp/memory-hook.log)
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-start-stale.json" > "$input_expanded"
# Run hook
local stdout exit_code=0
stdout=$(HOME="$sb" PATH="$sb/bin:$PATH" GRAPHIFY_SHIM_LOG="$sb/graphify-shim.log" \
bash "$HOOKS_DIR/session-start.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Poll for graphify shim log (background nohup'd call)
for i in $(seq 1 10); do
[[ -f "$sb/graphify-shim.log" ]] && break
sleep 0.5
done
# Write main fixtures
write_fixture "session-start-stale" "$sb" "$stdout" "$exit_code" "$INPUTS_DIR/session-start-stale.json"
# Side effect: memory-hook.log delta
if [[ -f /tmp/memory-hook.log ]]; then
dd if=/tmp/memory-hook.log bs=1 skip="$log_offset" 2>/dev/null \
| normalize "$sb" > "$sdir/sideeffects/memory-hook-log-delta.txt"
else
echo "(no log entries)" > "$sdir/sideeffects/memory-hook-log-delta.txt"
fi
# Side effect: graphify shim args
if [[ -f "$sb/graphify-shim.log" ]]; then
normalize "$sb" < "$sb/graphify-shim.log" > "$sdir/sideeffects/graphify-shim-args.txt"
else
echo "(graphify not invoked within 5s)" > "$sdir/sideeffects/graphify-shim-args.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "log delta: $(cat "$sdir/sideeffects/memory-hook-log-delta.txt")"
echo "graphify shim: $(cat "$sdir/sideeffects/graphify-shim-args.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-context-first-with-graph
# ---------------------------------------------------------------------------
run_session_context_first_with_graph() {
echo ""
echo "--- session-context-first-with-graph ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-context-first-with-graph")
local sdir="$GOLDEN_DIR/session-context-first-with-graph"
mkdir -p "$sdir/sideeffects"
# Setup: create a real git repo with graph.json
mkdir -p "$sb/vault-project/graphify-out"
echo '{"nodes":[],"edges":[]}' > "$sb/vault-project/graphify-out/graph.json"
/usr/bin/git init "$sb/vault-project" >/dev/null 2>&1
/usr/bin/git -C "$sb/vault-project" config user.email "test@test.com"
/usr/bin/git -C "$sb/vault-project" config user.name "Test"
/usr/bin/git -C "$sb/vault-project" commit --allow-empty -m "init" >/dev/null 2>&1
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-context-first-with-graph.json" > "$input_expanded"
# Run hook from vault-project cwd
local stdout exit_code=0
stdout=$(cd "$sb/vault-project" && HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/session-context.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Write main fixtures
write_fixture "session-context-first-with-graph" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/session-context-first-with-graph.json"
# Side effect: flag file created?
if [[ -f /tmp/memory-context-injected-golden-sess-003 ]]; then
echo "yes" > "$sdir/sideeffects/flag-file-created.txt"
else
echo "no" > "$sdir/sideeffects/flag-file-created.txt"
fi
echo "stdout (normalized): $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "flag created: $(cat "$sdir/sideeffects/flag-file-created.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-context-reentry
# ---------------------------------------------------------------------------
run_session_context_reentry() {
echo ""
echo "--- session-context-reentry ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-context-reentry")
local sdir="$GOLDEN_DIR/session-context-reentry"
mkdir -p "$sdir/sideeffects"
# Setup: flag file already exists (reentry)
touch /tmp/memory-context-injected-golden-sess-004
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-context-reentry.json" > "$input_expanded"
# Run hook from /tmp
local stdout exit_code=0
stdout=$(cd /tmp && HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/session-context.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Write main fixtures
write_fixture "session-context-reentry" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/session-context-reentry.json"
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-context-no-graph
# ---------------------------------------------------------------------------
run_session_context_no_graph() {
echo ""
echo "--- session-context-no-graph ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-context-no-graph")
local sdir="$GOLDEN_DIR/session-context-no-graph"
mkdir -p "$sdir/sideeffects"
# Setup: git repo but NO graphify-out/graph.json
mkdir -p "$sb/noproject"
/usr/bin/git init "$sb/noproject" >/dev/null 2>&1
/usr/bin/git -C "$sb/noproject" config user.email "test@test.com"
/usr/bin/git -C "$sb/noproject" config user.name "Test"
/usr/bin/git -C "$sb/noproject" commit --allow-empty -m "init" >/dev/null 2>&1
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-context-no-graph.json" > "$input_expanded"
# Run hook from noproject cwd
local stdout exit_code=0
stdout=$(cd "$sb/noproject" && HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/session-context.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Write main fixtures
write_fixture "session-context-no-graph" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/session-context-no-graph.json"
echo "stdout (normalized): $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: post-tool-use-vault-md
# ---------------------------------------------------------------------------
run_post_tool_use_vault_md() {
echo ""
echo "--- post-tool-use-vault-md ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "post-tool-use-vault-md")
local sdir="$GOLDEN_DIR/post-tool-use-vault-md"
mkdir -p "$sdir/sideeffects"
# Setup: fresh stamp + vault notes dir
touch "$sb/.cache/graphify/vault-rebuild.stamp"
mkdir -p "$sb/vault/notes"
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/post-tool-use-vault-md.json" > "$input_expanded"
# Run hook
local stdout exit_code=0
stdout=$(HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/post-tool-use-write.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Write main fixtures
write_fixture "post-tool-use-vault-md" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/post-tool-use-vault-md.json"
# Side effect: touch file content
if [[ -f /tmp/claude-vault-touched-golden-sess-006 ]]; then
normalize "$sb" < /tmp/claude-vault-touched-golden-sess-006 \
> "$sdir/sideeffects/touch-file.txt"
else
echo "(not created)" > "$sdir/sideeffects/touch-file.txt"
fi
# Side effect: post-tool-use.log
if [[ -f "$sb/.cache/graphify/post-tool-use.log" ]]; then
normalize "$sb" < "$sb/.cache/graphify/post-tool-use.log" \
> "$sdir/sideeffects/post-tool-use-log.txt"
else
echo "(not created)" > "$sdir/sideeffects/post-tool-use-log.txt"
fi
# Side effect: stamp deleted?
if [[ ! -f "$sb/.cache/graphify/vault-rebuild.stamp" ]]; then
echo "yes" > "$sdir/sideeffects/stamp-deleted.txt"
else
echo "no" > "$sdir/sideeffects/stamp-deleted.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "touch-file: $(cat "$sdir/sideeffects/touch-file.txt")"
echo "log: $(cat "$sdir/sideeffects/post-tool-use-log.txt")"
echo "stamp-deleted: $(cat "$sdir/sideeffects/stamp-deleted.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: post-tool-use-non-md
# ---------------------------------------------------------------------------
run_post_tool_use_non_md() {
echo ""
echo "--- post-tool-use-non-md ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "post-tool-use-non-md")
local sdir="$GOLDEN_DIR/post-tool-use-non-md"
mkdir -p "$sdir/sideeffects"
mkdir -p "$sb/vault/notes"
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/post-tool-use-non-md.json" > "$input_expanded"
# Run hook
local stdout exit_code=0
stdout=$(HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/post-tool-use-write.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
write_fixture "post-tool-use-non-md" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/post-tool-use-non-md.json"
# Confirm touch file and log were NOT created (no-op)
if [[ -f /tmp/claude-vault-touched-golden-sess-006b ]]; then
echo "UNEXPECTED: touch file was created" > "$sdir/sideeffects/noops.txt"
else
echo "ok: no touch file (non-md guard fired)" > "$sdir/sideeffects/noops.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "noops: $(cat "$sdir/sideeffects/noops.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: post-tool-use-outside-vault
# ---------------------------------------------------------------------------
run_post_tool_use_outside_vault() {
echo ""
echo "--- post-tool-use-outside-vault ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "post-tool-use-outside-vault")
local sdir="$GOLDEN_DIR/post-tool-use-outside-vault"
mkdir -p "$sdir/sideeffects"
# Expand input (no __SANDBOX__ in this one but run through sed anyway)
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/post-tool-use-outside-vault.json" > "$input_expanded"
# Run hook
local stdout exit_code=0
stdout=$(HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/post-tool-use-write.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
write_fixture "post-tool-use-outside-vault" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/post-tool-use-outside-vault.json"
if [[ -f /tmp/claude-vault-touched-golden-sess-006c ]]; then
echo "UNEXPECTED: touch file was created" > "$sdir/sideeffects/noops.txt"
else
echo "ok: no touch file (outside-vault guard fired)" > "$sdir/sideeffects/noops.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "noops: $(cat "$sdir/sideeffects/noops.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-end-with-touches
# SAFETY: real HEAD must be verified after this run.
# ---------------------------------------------------------------------------
run_session_end_with_touches() {
echo ""
echo "--- session-end-with-touches ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-end-with-touches")
local sdir="$GOLDEN_DIR/session-end-with-touches"
mkdir -p "$sdir/sideeffects"
# Git shim preflight
echo " [preflight] Testing git shim..."
"$sb/bin/git" -C /home/jared/.memsearch status >/dev/null 2>/dev/null
if [[ -f "$sb/git-shim.log" ]]; then
echo " [preflight] PASS — shim intercepted /home/jared/.memsearch call"
cat "$sb/git-shim.log"
# Clear the preflight entry so fixture only captures hook-time calls
rm -f "$sb/git-shim.log"
else
echo " [preflight] WARNING — shim log not found; proceeding anyway"
fi
# Setup: touch file with two paths
printf '%s\n%s\n' "${sb}/vault/notes/test.md" "${sb}/vault/notes/other.md" \
> /tmp/claude-vault-touched-golden-sess-007
# Ensure journal dir exists
mkdir -p "$sb/vault/journal"
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-end-with-touches.json" > "$input_expanded"
# Run hook from /tmp (avoids git rev-parse returning cc-os repo as project)
local stdout exit_code=0
stdout=$(cd /tmp && HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/session-end.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# IMMEDIATE HEAD CHECK after first session-end run
local head_after
head_after=$(git -C /home/jared/.memsearch rev-parse HEAD 2>/dev/null || echo "NO_REAL_REPO")
echo " [safety] HEAD after session-end-with-touches: $head_after"
if [[ "$head_after" != "$REAL_HEAD_BEFORE" ]]; then
echo "SAFETY BREACH: memsearch HEAD changed! $head_after != $REAL_HEAD_BEFORE" >&2
rm -rf "$sb"
exit 99
fi
echo " [safety] PASS"
write_fixture "session-end-with-touches" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/session-end-with-touches.json"
# Side effect: journal content (find by glob, not recomputing date)
local journal_file
journal_file=$(ls "$sb/vault/journal/"*.md 2>/dev/null | head -1)
if [[ -n "$journal_file" && -f "$journal_file" ]]; then
normalize "$sb" < "$journal_file" > "$sdir/sideeffects/journal.txt"
else
echo "(journal file not created)" > "$sdir/sideeffects/journal.txt"
fi
# Side effect: git shim log
if [[ -f "$sb/git-shim.log" ]]; then
normalize "$sb" < "$sb/git-shim.log" > "$sdir/sideeffects/git-shim-log.txt"
else
echo "(no git shim log — memsearch block not triggered?)" > "$sdir/sideeffects/git-shim-log.txt"
fi
# Side effect: touch file deleted?
if [[ ! -f /tmp/claude-vault-touched-golden-sess-007 ]]; then
echo "deleted" > "$sdir/sideeffects/touch-file-deleted.txt"
else
echo "NOT deleted (unexpected)" > "$sdir/sideeffects/touch-file-deleted.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "journal (first 10 lines):"
head -10 "$sdir/sideeffects/journal.txt" || true
echo "git-shim-log: $(cat "$sdir/sideeffects/git-shim-log.txt")"
echo "touch-file-deleted: $(cat "$sdir/sideeffects/touch-file-deleted.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# SCENARIO: session-end-no-touches
# ---------------------------------------------------------------------------
run_session_end_no_touches() {
echo ""
echo "--- session-end-no-touches ---"
cleanup_tmp_flags
local sb
sb=$(make_sandbox "session-end-no-touches")
local sdir="$GOLDEN_DIR/session-end-no-touches"
mkdir -p "$sdir/sideeffects"
# Setup: NO touch file for golden-sess-008
mkdir -p "$sb/vault/journal"
# Expand input
local input_expanded="$sb/input-expanded.json"
sed "s|__SANDBOX__|${sb}|g" "$INPUTS_DIR/session-end-no-touches.json" > "$input_expanded"
# Run hook from /tmp
local stdout exit_code=0
stdout=$(cd /tmp && HOME="$sb" PATH="$sb/bin:$PATH" \
bash "$HOOKS_DIR/session-end.sh" < "$input_expanded" 2>/dev/null) || exit_code=$?
# Safety check
local head_after
head_after=$(git -C /home/jared/.memsearch rev-parse HEAD 2>/dev/null || echo "NO_REAL_REPO")
echo " [safety] HEAD after session-end-no-touches: $head_after"
if [[ "$head_after" != "$REAL_HEAD_BEFORE" ]]; then
echo "SAFETY BREACH: memsearch HEAD changed!" >&2
rm -rf "$sb"
exit 99
fi
echo " [safety] PASS"
write_fixture "session-end-no-touches" "$sb" "$stdout" "$exit_code" \
"$INPUTS_DIR/session-end-no-touches.json"
# Side effect: journal content
local journal_file
journal_file=$(ls "$sb/vault/journal/"*.md 2>/dev/null | head -1)
if [[ -n "$journal_file" && -f "$journal_file" ]]; then
normalize "$sb" < "$journal_file" > "$sdir/sideeffects/journal.txt"
else
echo "(journal file not created)" > "$sdir/sideeffects/journal.txt"
fi
# Side effect: git shim log
if [[ -f "$sb/git-shim.log" ]]; then
normalize "$sb" < "$sb/git-shim.log" > "$sdir/sideeffects/git-shim-log.txt"
else
echo "(no git shim log — memsearch block not triggered?)" > "$sdir/sideeffects/git-shim-log.txt"
fi
echo "stdout: $(cat "$sdir/stdout.txt")"
echo "exit_code: $exit_code"
echo "journal (first 10 lines):"
head -10 "$sdir/sideeffects/journal.txt" || true
echo "git-shim-log: $(cat "$sdir/sideeffects/git-shim-log.txt")"
rm -rf "$sb"
}
# ---------------------------------------------------------------------------
# Run all scenarios
# ---------------------------------------------------------------------------
echo "=== Generating golden fixtures ==="
run_session_start_fresh
run_session_start_stale
run_session_context_first_with_graph
run_session_context_reentry
run_session_context_no_graph
run_post_tool_use_vault_md
run_post_tool_use_non_md
run_post_tool_use_outside_vault
run_session_end_with_touches # safety-critical — HEAD checked immediately after
run_session_end_no_touches
# ---------------------------------------------------------------------------
# Final safety check
# ---------------------------------------------------------------------------
echo ""
echo "=== FINAL SAFETY CHECK ==="
REAL_HEAD_AFTER=$(git -C /home/jared/.memsearch rev-parse HEAD 2>/dev/null || echo "NO_REAL_REPO")
echo "BEFORE: $REAL_HEAD_BEFORE"
echo "AFTER: $REAL_HEAD_AFTER"
if [[ "$REAL_HEAD_AFTER" == "$REAL_HEAD_BEFORE" ]]; then
echo "SAFETY CHECK PASSED — real memsearch HEAD unchanged"
else
echo "SAFETY CHECK FAILED — HEAD CHANGED from $REAL_HEAD_BEFORE to $REAL_HEAD_AFTER"
exit 1
fi
echo ""
echo "=== All fixtures written to $GOLDEN_DIR ==="
find "$GOLDEN_DIR" -type f | sort

View File

@ -0,0 +1 @@
0

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-006b", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.py"}}

View File

@ -0,0 +1 @@
ok: no touch file (non-md guard fired)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-006c", "tool_input": {"file_path": "/tmp/outside.md"}}

View File

@ -0,0 +1 @@
ok: no touch file (outside-vault guard fired)

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-006", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.md"}}

View File

@ -0,0 +1 @@
[__TIMESTAMP__] Invalidated rebuild stamp: __SANDBOX__/vault/notes/test.md

View File

@ -0,0 +1 @@
__SANDBOX__/vault/notes/test.md

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-003", "cwd": "__SANDBOX__/vault-project"}

View File

@ -0,0 +1 @@
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "## Project Graph\n\nProject graph: __SANDBOX__/vault-project/graphify-out/graph.json\n"}}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-005", "cwd": "__SANDBOX__/noproject"}

View File

@ -0,0 +1 @@
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ""}}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-004", "cwd": "/tmp"}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-008", "reason": "timeout"}

View File

@ -0,0 +1,3 @@
[git-shim] BLOCKED: git -C __REAL_HOME__/.memsearch add -A
[git-shim] BLOCKED: git -C __REAL_HOME__/.memsearch diff --cached --quiet
[git-shim] BLOCKED: git -C __REAL_HOME__/.memsearch push --quiet origin main

View File

@ -0,0 +1,11 @@
---
summary: Daily session log
tags: [scope/global, type/log]
---
## Session — __TIMESTAMP__
**Project:** unknown
**Reason:** timeout
**Vault notes touched:**
(none)

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-007", "reason": "normal"}

View File

@ -0,0 +1,3 @@
[git-shim] BLOCKED: git -C __REAL_HOME__/.memsearch add -A
[git-shim] BLOCKED: git -C __REAL_HOME__/.memsearch diff --cached --quiet
[git-shim] BLOCKED: git -C __REAL_HOME__/.memsearch push --quiet origin main

View File

@ -0,0 +1,12 @@
---
summary: Daily session log
tags: [scope/global, type/log]
---
## Session — __TIMESTAMP__
**Project:** unknown
**Reason:** normal
**Vault notes touched:**
__SANDBOX__/vault/notes/test.md
__SANDBOX__/vault/notes/other.md

View File

@ -0,0 +1 @@
0

View File

@ -0,0 +1 @@
{"source": "ide", "cwd": "/home/jared/dev/test-project"}

View File

@ -0,0 +1,2 @@
[__TIMESTAMP__] session-start fired: source=ide cwd=__CWD__
[__TIMESTAMP__] session-start: rebuild not needed

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@
0

View File

@ -0,0 +1 @@
{"source": "cli", "cwd": "/home/jared/dev/test-project"}

View File

@ -0,0 +1 @@
[fake-graphify] args: extract __SANDBOX__/vault --backend ollama --model qwen25-coder-7b-16k --force

View File

@ -0,0 +1,2 @@
[__TIMESTAMP__] session-start fired: source=cli cwd=__CWD__
[__TIMESTAMP__] session-start: rebuild triggered

View File

@ -0,0 +1 @@

247
plugins/memory/tests/harness.sh Executable file
View File

@ -0,0 +1,247 @@
#!/usr/bin/env bash
# Golden fixture harness for memory plugin hooks
# Usage: ./harness.sh <hook-name> <input-json-file> [--scenario-dir <dir>]
# Builds sandbox, runs hook, emits normalized stdout+sideeffects for diffing
#
# Normalization rules (applied in this order to avoid corruption):
# 1. ISO 8601 timestamps (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) → __TIMESTAMP__
# 2. Date-only (\d{4}-\d{2}-\d{2}) → __DATE__
# 3. Sandbox temp path ($SANDBOX) → __SANDBOX__
# 4. cwd=<path> log values → cwd=__CWD__ (varies by machine/invocation dir)
# 5. Real home /home/jared → __REAL_HOME__
#
# SAFETY: git shim blocks any git call targeting /home/jared/.memsearch
set -euo pipefail
# Set HOOKS_DIR to override, e.g. for Python port replay:
# HOOKS_DIR=/path/to/python-hooks ./harness.sh ...
HOOKS_DIR="${HOOKS_DIR:-$(cd "$(dirname "$0")/../hooks" && pwd -P)}"
usage() {
echo "Usage: $0 <hook-name> <input-json-file> [--scenario-dir <dir>]" >&2
exit 1
}
[[ $# -lt 2 ]] && usage
HOOK_NAME="$1"
INPUT_JSON_FILE="$2"
SCENARIO_DIR=""
shift 2
while [[ $# -gt 0 ]]; do
case "$1" in
--scenario-dir) SCENARIO_DIR="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; usage ;;
esac
done
# Validate hook name
HOOK_SCRIPT="$HOOKS_DIR/${HOOK_NAME}.sh"
if [[ ! -f "$HOOK_SCRIPT" ]]; then
echo "Hook script not found: $HOOK_SCRIPT" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# 1. Create sandbox
# ---------------------------------------------------------------------------
SANDBOX=$(mktemp -d)
# Canonicalize to avoid symlink leaks (e.g. /tmp → /private/tmp on macOS)
SANDBOX=$(cd "$SANDBOX" && pwd -P)
cleanup() {
rm -rf "$SANDBOX"
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# 2. Populate sandbox structure
# ---------------------------------------------------------------------------
mkdir -p "$SANDBOX/.claude/plugins/memory"
cat > "$SANDBOX/.claude/plugins/memory/config.yaml" <<YAML
vault_path: ${SANDBOX}/vault
graphify_output_dir: ${SANDBOX}/vault/graphify-out
ollama_model: qwen25-coder-7b-16k
stale_threshold_days: 7
memsearch_dir: /home/jared/.memsearch
YAML
mkdir -p "$SANDBOX/vault/journal"
mkdir -p "$SANDBOX/.cache/graphify"
mkdir -p "$SANDBOX/bin"
# ---------------------------------------------------------------------------
# 3. Graphify shim
# ---------------------------------------------------------------------------
export GRAPHIFY_SHIM_LOG="$SANDBOX/graphify-shim.log"
cat > "$SANDBOX/bin/graphify" <<BASH
#!/usr/bin/env bash
echo "[fake-graphify] args: \$@" >> "${SANDBOX}/graphify-shim.log"
exit 0
BASH
chmod +x "$SANDBOX/bin/graphify"
# ---------------------------------------------------------------------------
# 4. Git shim — SAFETY CRITICAL
# Absolute log path baked in at write-time (not env-var-only) so it works
# inside session-end's subshell even if env is stripped.
# Emits NOTHING to stdout/stderr — only writes to log file.
# Blocks any git call with /home/jared/.memsearch as an argument.
# ---------------------------------------------------------------------------
export SANDBOX_GIT_SHIM_LOG="$SANDBOX/git-shim.log"
cat > "$SANDBOX/bin/git" <<BASH
#!/usr/bin/env bash
# Safety shim: block any git operation targeting the real memsearch repo
for arg in "\$@"; do
if [ "\$arg" = "/home/jared/.memsearch" ]; then
echo "[git-shim] BLOCKED: git \$@ (memsearch path detected)" >> "${SANDBOX}/git-shim.log"
exit 0
fi
done
exec /usr/bin/git "\$@"
BASH
chmod +x "$SANDBOX/bin/git"
# ---------------------------------------------------------------------------
# 5. Expand __SANDBOX__ in input JSON, write to temp file
# ---------------------------------------------------------------------------
INPUT_EXPANDED="$SANDBOX/input-expanded.json"
sed "s|__SANDBOX__|${SANDBOX}|g" "$INPUT_JSON_FILE" > "$INPUT_EXPANDED"
# ---------------------------------------------------------------------------
# 6. Export environment for hook run
# ---------------------------------------------------------------------------
export PATH="$SANDBOX/bin:$PATH"
export HOME="$SANDBOX"
# ---------------------------------------------------------------------------
# 7. Normalization function
# ---------------------------------------------------------------------------
normalize() {
local sandbox_path="$1"
sed \
-e "s|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T[0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}Z|__TIMESTAMP__|g" \
-e "s|[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}|__DATE__|g" \
-e "s|${sandbox_path}|__SANDBOX__|g" \
-e "s|cwd=[^ ]*|cwd=__CWD__|g" \
-e "s|/home/jared|__REAL_HOME__|g"
}
# ---------------------------------------------------------------------------
# 8. Record log file offset before run (for delta capture)
# ---------------------------------------------------------------------------
LOG_OFFSET=0
if [[ -f /tmp/memory-hook.log ]]; then
LOG_OFFSET=$(wc -c < /tmp/memory-hook.log)
fi
# ---------------------------------------------------------------------------
# 9. Run hook
# ---------------------------------------------------------------------------
HOOK_STDOUT=""
HOOK_EXIT=0
HOOK_STDOUT=$(bash "$HOOK_SCRIPT" < "$INPUT_EXPANDED" 2>/dev/null) || HOOK_EXIT=$?
# For session-start-stale: poll for graphify-shim.log up to 5 seconds
if [[ "$HOOK_NAME" == "session-start" ]]; then
for i in $(seq 1 10); do
if [[ -f "$SANDBOX/graphify-shim.log" ]]; then
break
fi
sleep 0.5
done
fi
# ---------------------------------------------------------------------------
# 10. Output normalized stdout and exit code
# ---------------------------------------------------------------------------
echo "$HOOK_STDOUT" | normalize "$SANDBOX"
echo "EXIT_CODE: $HOOK_EXIT"
# ---------------------------------------------------------------------------
# 11. If scenario-dir given, write artifacts there
# ---------------------------------------------------------------------------
if [[ -n "$SCENARIO_DIR" ]]; then
mkdir -p "$SCENARIO_DIR/sideeffects"
echo "$HOOK_STDOUT" | normalize "$SANDBOX" > "$SCENARIO_DIR/stdout.txt"
echo "$HOOK_EXIT" > "$SCENARIO_DIR/exit_code.txt"
cp "$INPUT_JSON_FILE" "$SCENARIO_DIR/input.json"
# Memory hook log delta
if [[ -f /tmp/memory-hook.log ]]; then
dd if=/tmp/memory-hook.log bs=1 skip="$LOG_OFFSET" 2>/dev/null \
| normalize "$SANDBOX" \
> "$SCENARIO_DIR/sideeffects/memory-hook-log-delta.txt"
else
echo "(no log entries)" > "$SCENARIO_DIR/sideeffects/memory-hook-log-delta.txt"
fi
# Graphify shim log
if [[ -f "$SANDBOX/graphify-shim.log" ]]; then
normalize "$SANDBOX" < "$SANDBOX/graphify-shim.log" \
> "$SCENARIO_DIR/sideeffects/graphify-shim-args.txt"
fi
# Git shim log
if [[ -f "$SANDBOX/git-shim.log" ]]; then
normalize "$SANDBOX" < "$SANDBOX/git-shim.log" \
> "$SCENARIO_DIR/sideeffects/git-shim-log.txt"
fi
# ---------------------------------------------------------------------------
# Hook-specific side effects
# ---------------------------------------------------------------------------
SESSION_ID=$(python3 -c "import json; d=json.load(open('$INPUT_EXPANDED')); print(d.get('session_id','unknown'))" 2>/dev/null || echo "unknown")
if [[ "$HOOK_NAME" == "session-end" ]]; then
# Journal file (find by glob)
local journal_file
journal_file=$(ls "$SANDBOX/vault/journal/"*.md 2>/dev/null | head -1)
if [[ -n "$journal_file" && -f "$journal_file" ]]; then
normalize "$SANDBOX" < "$journal_file" > "$SCENARIO_DIR/sideeffects/journal.txt"
else
echo "(journal file not created)" > "$SCENARIO_DIR/sideeffects/journal.txt"
fi
# Touch file deleted?
if [[ ! -f "/tmp/claude-vault-touched-${SESSION_ID}" ]]; then
echo "deleted" > "$SCENARIO_DIR/sideeffects/touch-file-deleted.txt"
else
echo "NOT deleted (unexpected)" > "$SCENARIO_DIR/sideeffects/touch-file-deleted.txt"
fi
fi
if [[ "$HOOK_NAME" == "post-tool-use-write" ]]; then
# Touch file content
if [[ -f "/tmp/claude-vault-touched-${SESSION_ID}" ]]; then
normalize "$SANDBOX" < "/tmp/claude-vault-touched-${SESSION_ID}" \
> "$SCENARIO_DIR/sideeffects/touch-file.txt"
else
echo "(not created)" > "$SCENARIO_DIR/sideeffects/touch-file.txt"
fi
# Post-tool-use log
if [[ -f "$SANDBOX/.cache/graphify/post-tool-use.log" ]]; then
normalize "$SANDBOX" < "$SANDBOX/.cache/graphify/post-tool-use.log" \
> "$SCENARIO_DIR/sideeffects/post-tool-use-log.txt"
else
echo "(not created)" > "$SCENARIO_DIR/sideeffects/post-tool-use-log.txt"
fi
# Stamp deleted?
if [[ ! -f "$SANDBOX/.cache/graphify/vault-rebuild.stamp" ]]; then
echo "yes" > "$SCENARIO_DIR/sideeffects/stamp-deleted.txt"
else
echo "no" > "$SCENARIO_DIR/sideeffects/stamp-deleted.txt"
fi
fi
if [[ "$HOOK_NAME" == "session-context" ]]; then
# Flag file created?
if [[ -f "/tmp/memory-context-injected-${SESSION_ID}" ]]; then
echo "yes" > "$SCENARIO_DIR/sideeffects/flag-file-created.txt"
else
echo "no" > "$SCENARIO_DIR/sideeffects/flag-file-created.txt"
fi
fi
fi

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-006b", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.py"}}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-006c", "tool_input": {"file_path": "/tmp/outside.md"}}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-006", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.md"}}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-003", "cwd": "__SANDBOX__/vault-project"}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-005", "cwd": "__SANDBOX__/noproject"}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-004", "cwd": "/tmp"}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-008", "reason": "timeout"}

View File

@ -0,0 +1 @@
{"session_id": "golden-sess-007", "reason": "normal"}

View File

@ -0,0 +1 @@
{"source": "ide", "cwd": "/home/jared/dev/test-project"}

View File

@ -0,0 +1 @@
{"source": "cli", "cwd": "/home/jared/dev/test-project"}

View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Test-only adapter (see session-start.sh).
HOOKS="$(cd "$(dirname "$0")/../../hooks" && pwd)"
exec python3 "$HOOKS/post_tool_use_write.py"

View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Test-only adapter (see session-start.sh).
HOOKS="$(cd "$(dirname "$0")/../../hooks" && pwd)"
exec python3 "$HOOKS/session_context.py"

View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Test-only adapter: session-end is split into TWO Python entries in production
# (session_end.py + memsearch_sync.py, two SessionEnd registrations). The harness
# captures a single combined golden, so we drive both from one stdin here.
HOOKS="$(cd "$(dirname "$0")/../../hooks" && pwd)"
IN="$(cat)"
printf '%s' "$IN" | python3 "$HOOKS/session_end.py"
printf '%s' "$IN" | python3 "$HOOKS/memsearch_sync.py"

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Test-only adapter: lets the golden-fixture harness (which invokes
# bash "$HOOKS_DIR/<name>.sh") drive the Python port. Not used in production.
HOOKS="$(cd "$(dirname "$0")/../../hooks" && pwd)"
exec python3 "$HOOKS/session_start.py"