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:
parent
53a3c4c2c2
commit
48e4d77795
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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`).
|
**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.
|
**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
|
## Implemented Components
|
||||||
|
|
||||||
**Global memory plugin** — `~/.claude/plugins/memory/`
|
**Global memory plugin** — `cc-os/plugins/memory/` (git-tracked, 2026-06-12); symlinked into `~/.claude/plugins/memory`
|
||||||
- Hooks: `hooks/` — session-start.sh, session-context.sh (project graph path only), post-tool-use-write.sh, session-end.sh
|
- 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
|
- Skills: `skills/` — memory-vault, memory-write, memory-reorganize, memory-project
|
||||||
- Config: `config.yaml` — vault path, Ollama model (qwen25-coder-7b-16k), env vars
|
- 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`
|
**Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify`
|
||||||
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, rebuilt by SessionStart hook
|
- 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
|
- Index: `~/.memsearch/milvus.db` (Milvus Lite, local); embeddings via ONNX bge-m3
|
||||||
- Config: `MEMSEARCH_DIR=~/.memsearch` in `~/.zshrc` for global scope
|
- Config: `MEMSEARCH_DIR=~/.memsearch` in `~/.zshrc` for global scope
|
||||||
- Skills: `/memory-recall`, `/memory-config` (ship with plugin)
|
- 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).
|
**Not yet implemented:** Obsidian vault → VPS sync (Step 5b). Memsearch episodic git sync is done (Step 5a, 2026-06-09).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Architecture Decision Records
|
# 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 ·
|
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.
|
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;
|
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.
|
applying the same mechanism to the episodic store is consistent.
|
||||||
- **Status**: Accepted (commingling resolved 2026-06-09). Initial commit `106cebc` in the
|
- **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)
|
## Rejected tools (summary)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Build Plan
|
# 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:
|
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
|
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
|
`.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`).
|
`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
|
(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/.git` existing, commits only when staged content exists (message:
|
||||||
`memsearch: session memory <date>`), and pushes with `timeout 30 ... || true`. Wrapped in a
|
`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
|
4 skills (`memory-vault`, `memory-write`, `memory-reorganize`, `memory-project`). Plugin enabled in
|
||||||
`~/.claude/settings.json`.
|
`~/.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
|
### Step 7 — (SKIPPED) QMD semantic layer
|
||||||
Covered by Graphify. The knowledge-graph approach provides structured semantic retrieval
|
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
|
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 `--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
|
- 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
|
`<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
|
summary, convention summaries, journal pointer) was removed. The AI queries the vault or
|
||||||
project graph on demand rather than having it injected.
|
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 are thin shell wrappers; the logic lives in Graphify.
|
||||||
|
|
||||||
**Hooks summary:**
|
**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`.
|
- **PostToolUse** (on `Write`/`Edit` of vault `.md`) — `graphify update --file`.
|
||||||
- **SessionEnd** — append daily journal note.
|
- **SessionEnd** — append daily journal note.
|
||||||
- (memsearch brings its own hooks for the episodic layer.)
|
- (memsearch brings its own hooks for the episodic layer.)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-11
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
- **THEN** the hook creates the file with appropriate frontmatter (summary, scope/global, type/log) and appends the session entry
|
||||||
|
|
||||||
### Requirement: Plugin configuration block
|
### 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
|
#### Scenario: Default config is used
|
||||||
- **WHEN** the config file exists with no overrides for a setting
|
- **WHEN** the config file exists with no overrides for a setting
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -2,7 +2,7 @@ _Last updated: 2026-06-08_
|
||||||
|
|
||||||
## Scope
|
## 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
|
- Delegate all query mechanics (traversal, --budget, --dfs, graphify path, graphify explain) to `memory-vault` — do not duplicate
|
||||||
|
|
||||||
3.5 Cover **graph path discovery**:
|
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
|
- 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:
|
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
|
- **THEN** Claude runs `graphify update . --force` to clear ghost nodes
|
||||||
|
|
||||||
#### Scenario: Claude starts a session in a project with no graph
|
#### 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
|
- **AND** Claude is working in a project context
|
||||||
- **THEN** Claude suggests the onboard sequence before knowledge queries
|
- **THEN** Claude suggests the onboard sequence before knowledge queries
|
||||||
- **AND** Claude does NOT silently fall back to vault-only retrieval without noting the gap
|
- **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
|
- 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
|
- Vault graph reports
|
||||||
- Live convention queries
|
- Live convention queries
|
||||||
- Journal pointers
|
- 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
|
- **WHEN** `<project-root>/graphify-out/graph.json` exists
|
||||||
- **THEN** the script outputs the absolute path to the graph file
|
- **THEN** the script outputs the absolute path to the graph file
|
||||||
- **AND** no other memory context is emitted
|
- **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
|
- **WHEN** `<project-root>/graphify-out/graph.json` does not exist
|
||||||
- **THEN** the script outputs nothing (or a minimal "no project graph" note)
|
- **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.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
|
#### Scenario: Verifying mirror consistency
|
||||||
- **WHEN** any of the three skill directories is read
|
- **WHEN** any of the three skill directories is read
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Generated / rebuildable outputs
|
||||||
|
graphify-out/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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()},
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-006b", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.py"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ok: no touch file (non-md guard fired)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-006c", "tool_input": {"file_path": "/tmp/outside.md"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ok: no touch file (outside-vault guard fired)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-006", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.md"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[__TIMESTAMP__] Invalidated rebuild stamp: __SANDBOX__/vault/notes/test.md
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
yes
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
__SANDBOX__/vault/notes/test.md
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-003", "cwd": "__SANDBOX__/vault-project"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
yes
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "## Project Graph\n\nProject graph: __SANDBOX__/vault-project/graphify-out/graph.json\n"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-005", "cwd": "__SANDBOX__/noproject"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ""}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-004", "cwd": "/tmp"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-008", "reason": "timeout"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
summary: Daily session log
|
||||||
|
tags: [scope/global, type/log]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session — __TIMESTAMP__
|
||||||
|
|
||||||
|
**Project:** unknown
|
||||||
|
**Reason:** timeout
|
||||||
|
**Vault notes touched:**
|
||||||
|
(none)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-007", "reason": "normal"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
deleted
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"source": "ide", "cwd": "/home/jared/dev/test-project"}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[__TIMESTAMP__] session-start fired: source=ide cwd=__CWD__
|
||||||
|
[__TIMESTAMP__] session-start: rebuild not needed
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"source": "cli", "cwd": "/home/jared/dev/test-project"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[fake-graphify] args: extract __SANDBOX__/vault --backend ollama --model qwen25-coder-7b-16k --force
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[__TIMESTAMP__] session-start fired: source=cli cwd=__CWD__
|
||||||
|
[__TIMESTAMP__] session-start: rebuild triggered
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-006b", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.py"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-006c", "tool_input": {"file_path": "/tmp/outside.md"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-006", "tool_input": {"file_path": "__SANDBOX__/vault/notes/test.md"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-003", "cwd": "__SANDBOX__/vault-project"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-005", "cwd": "__SANDBOX__/noproject"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-004", "cwd": "/tmp"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-008", "reason": "timeout"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"session_id": "golden-sess-007", "reason": "normal"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"source": "ide", "cwd": "/home/jared/dev/test-project"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"source": "cli", "cwd": "/home/jared/dev/test-project"}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue