Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "mnemo",
"description": "7 memory skills for Claude Code and Codex via Obsidian — vault audit, knowledge search, bounded cross-runtime recall, connection discovery, session handoff, memory routing, review orchestration, and setup",
"version": "1.2.8",
"version": "1.2.9",
"source": "./plugins/mnemo",
"category": "productivity",
"keywords": [
Expand Down
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [1.2.9] - 2026-07-21

### Changed

- **Stop nudge now recommends the one-command close-out `/mn:review --full`** instead of listing `/mn:save` and `/mn:session` separately — aligning the automatic end-of-session reminder with the v1.2.8 one-command close-out (Codex: `$mnemo:review --full`). The gating is unchanged: opt-in `hooks.stopNudge`, worth-saving signals ≥3, save/session not yet run, blocks at most once per session.

### Fixed

- **Stop nudge over-firing in Codex** — the anti-loop governor keyed only on the Stop payload's `session_id`, which a Codex Stop payload can omit or vary, so the once-per-session marker failed to dedup and the nudge could fire on every Stop. It now falls back to `CODEX_THREAD_ID` / `CODEX_SESSION_ID` for a stable per-thread key. New `scripts/test-stop-nudge.py` (7 cases) pins the `--full` recommendation, both runtimes' syntax, once-per-session dedup, the Codex thread-key fallback, and the silence conditions (below-threshold signals, save+session already ran, recursion guard).

## [1.2.8] - 2026-07-21

### Added
Expand Down Expand Up @@ -923,7 +933,8 @@ Frontmatter now includes `session_id: {CLAUDE_SESSION_ID}` — disambiguates sam
- `config.example.json`
- MIT License

[Unreleased]: https://github.com/jojoprison/mnemo/compare/v1.2.8...HEAD
[Unreleased]: https://github.com/jojoprison/mnemo/compare/v1.2.9...HEAD
[1.2.9]: https://github.com/jojoprison/mnemo/compare/v1.2.8...v1.2.9
[1.2.8]: https://github.com/jojoprison/mnemo/compare/v1.2.7...v1.2.8
[1.2.7]: https://github.com/jojoprison/mnemo/compare/v1.2.6...v1.2.7
[1.2.6]: https://github.com/jojoprison/mnemo/compare/v1.2.5...v1.2.6
Expand Down
2 changes: 1 addition & 1 deletion docs/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ mnemo saves two different kinds of thing, and they must land in different places
Descriptions get an agent to *consider* mnemo, but Opus 4.8 / Fable 5 under-trigger skills — a good description raises the odds, it doesn't guarantee the call. v1.1.1 adds a deterministic **delivery** layer via hooks, with one honest caveat baked into the design: **deterministic delivery ≠ deterministic effect** — the hook always fires, but the model still decides whether to act. A prose nudge is exactly the "marginal rule ≈ 0" pattern, so it's kept short, factual (not an order), and gated.

- **SessionStart nudge** (`hooks/mnemo-context.sh`, `hooks.sessionStartNudge` default **true**) — one line: mnemo memory exists, recall with `/mn:ask` before non-trivial work, save with `/mn:save` as you go. Gated on a configured vault (silent otherwise). Cost: a few dozen tokens every session — an accepted always-on price for keeping memory top-of-mind. **One channel only:** the nudge lives in the hook, *not* duplicated into a committed `CLAUDE.md` line (that would be a second always-on copy and a committed→private cross-link leak).
- **Stop nudge** (`hooks/mnemo-stop-nudge.sh`, `hooks.stopNudge` default **false**) — if a session looks worth-saving (fix/decision signals) but `/mn:save` and/or `/mn:session` never ran, it blocks the stop **once**, listing whichever is missing (save pins discrete facts; session writes the narrative + handoff). Blocking is powerful but can loop for arbitrary users of a public plugin, so it's **opt-in**, and an anti-loop governor blocks at most once per session. The default install never blocks.
- **Stop nudge** (`hooks/mnemo-stop-nudge.sh`, `hooks.stopNudge` default **false**) — if a session looks worth-saving (fix/decision signals) but `/mn:save` and/or `/mn:session` never ran, it blocks the stop **once** and recommends the one-command close-out `/mn:review --full` (v1.2.9 — it audits, then chains save → session → connect + verify), instead of listing save and session separately. Blocking is powerful but can loop for arbitrary users of a public plugin, so it's **opt-in**, and an anti-loop governor blocks at most once per session — keyed on `session_id`, falling back to `CODEX_THREAD_ID` when a Codex Stop payload omits it, so it dedups instead of re-nudging every Stop. The default install never blocks.
- **Invocation echo** (`hooks/mnemo-skill-echo.sh`, `hooks.invocationEcho` default **true**, v1.2.2) — unifying skills removed the command-router layer, and with it the visible Skill-tool call users relied on to *see* that a `/mn:*` command loaded its skill. Visibility comes back in two layers: an **in-body invocation marker** (each `SKILL.md` opens its reply with `🧠 mn:<skill> (mnemo) → running` — both runtimes, probabilistic like every body instruction) and this **deterministic** Claude Code hook on `UserPromptExpansion` (fires on every `/mn:*` expansion; live-verified on CC 2.1.215 that hook output never alters the expansion). The event fires only on **user-typed** `/mn:*` — a model-initiated (self-invoked) skill run never triggers it and is covered by the in-body marker alone. Codex does not support this event, and Codex UI has no native invocation indicator, which is exactly why the in-body marker exists.
- **Runtime-safe hook composition** (v1.2.3; Claude loader fix v1.2.4) — `hooks/hooks.json` is the auto-discovered Codex-safe baseline (`SessionStart` + `Stop`). Claude's manifest lists only the additive `hooks/claude-hooks.json`, which contains `UserPromptExpansion`; explicitly listing the standard file as well makes current Claude Code reject the plugin as a duplicate. Each event still has one definition: Claude keeps all three behaviors, while Codex never has to ignore an undocumented event. The Codex manifest deliberately relies on default discovery because the bundled validator rejects an explicit `hooks` field even though the current manual documents it.
- **Rejected:** `PreToolUse(Read)` auto-recall (needs an index/daemon — mnemo has neither), `UserPromptSubmit` nudges (a cost on every prompt), and a **default-on** blocking Stop (loop risk for others). The nudge measures its own worth: ship it only if a trigger-eval shows lift over the bare description — otherwise it's paying an always-on price for nothing.
Expand Down
2 changes: 1 addition & 1 deletion plugins/mnemo/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "mn",
"displayName": "mnemo",
"version": "1.2.8",
"version": "1.2.9",
"description": "Persistent memory layer for Claude Code and Codex via Obsidian — 7 skills for vault health, knowledge search, verified cross-runtime project recall, connection discovery, session continuity, memory routing, skill-aware session review, and interactive setup. Bundled JSON-stdin atomic vault writer, argv-safe indexed reads, dual-runtime helpers, hybrid model routing, parallel CLI calls, SessionStart prewarm, incremental JSONL parsing, shared references, and semantic taxonomy roles.",
"author": {
"name": "jojoprison",
Expand Down
2 changes: 1 addition & 1 deletion plugins/mnemo/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mnemo",
"version": "1.2.8",
"version": "1.2.9",
"description": "Persistent memory layer for Codex and Claude Code via Obsidian: vault health, knowledge search, verified cross-runtime project recall, link discovery, session notes, memory routing, review orchestration, and setup.",
"author": {
"name": "jojoprison",
Expand Down
23 changes: 12 additions & 11 deletions plugins/mnemo/hooks/mnemo-stop-nudge.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Stop nudge — if the session produced fixes/decisions worth keeping but save and/or
# session never ran, block the stop ONCE with a reason listing what's still missing,
# so the agent wraps up cleanly (save pins discrete facts; session writes the narrative + handoff).
# session never ran, block the stop ONCE and recommend the one-command close-out
# (/mn:review --full), so the agent wraps up cleanly instead of leaving the work unsaved.
#
# Safety design (a public plugin must not trap arbitrary users in a loop):
# - Config-gated: hooks.stopNudge defaults to FALSE. A blocking Stop hook only runs when
Expand Down Expand Up @@ -38,6 +38,11 @@ IFS=$'\t' read -r SESSION TRANSCRIPT STOP_ACTIVE < <(
printf '%s' "$INPUT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('session_id','unknown'),d.get('transcript_path',''),int(bool(d.get('stop_hook_active'))),sep='\t')" 2>/dev/null \
|| printf 'unknown\t\t0\n'
)
# Stable anti-loop key: Codex Stop payloads may omit session_id — fall back to the
# Codex thread env so the once-per-session governor dedups instead of re-nudging every Stop.
if [ -z "${SESSION:-}" ] || [ "$SESSION" = "unknown" ]; then
SESSION="${CODEX_THREAD_ID:-${CODEX_SESSION_ID:-unknown}}"
fi
[ "${STOP_ACTIVE:-0}" = 1 ] && pass
[ -f "$TRANSCRIPT" ] || pass

Expand All @@ -64,21 +69,17 @@ SIGNALS="${SIGNALS:-0}"
# Worth-saving signals threshold (both EN + RU markers).
[ "${SIGNALS:-0}" -lt 3 ] 2>/dev/null && pass

# Build the list of what's still missing (save pins discrete facts; session writes the narrative + handoff).
# One-command close-out: recommend the review --full flag (it audits, then chains
# save → session → connect + verify) instead of nagging save and session separately.
if is_codex_runtime; then
SAVE_CMD='$mnemo:save'
SESSION_CMD='$mnemo:session'
CLOSE_CMD='$mnemo:review --full'
else
SAVE_CMD='/mn:save'
SESSION_CMD='/mn:session'
CLOSE_CMD='/mn:review --full'
fi
MISSING=""
[ "$SAVED" = 0 ] && MISSING="$SAVE_CMD"
[ "$SESSIONED" = 0 ] && MISSING="${MISSING:+$MISSING and }$SESSION_CMD"

MARKER_WRITTEN=$(python3 -c 'import sys;sys.path.insert(0,sys.argv[1]);from cache_utils import atomic_write_text;from pathlib import Path;print(atomic_write_text(Path(sys.argv[2]),"1"))' "$MNEMO_ROOT/scripts" "$MARKER" 2>/dev/null || echo False)
# Fail open if the anti-loop governor cannot be persisted. Blocking without a
# marker could trap the user in a repeated Stop cycle.
[ "$MARKER_WRITTEN" = "True" ] || pass
MSG="This session looks like it produced fixes or decisions worth keeping. Before wrapping up, run: ${MISSING}. Then stop again to proceed."
MSG="This session looks like it produced fixes or decisions worth keeping, and it isn't fully saved yet. Close it out in one command: ${CLOSE_CMD} — it audits the session, then saves the facts, writes the session note + handoff, and links them. Then stop again to proceed."
python3 -c "import json,sys; print(json.dumps({'decision':'block','reason':sys.argv[1]}, ensure_ascii=False))" "$MSG"
3 changes: 1 addition & 2 deletions scripts/test-runtime-compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,8 +715,7 @@ def test_stop_hook_uses_codex_block_schema_and_syntax(self) -> None:
)
payload = json.loads(result.stdout)
self.assertEqual(payload["decision"], "block")
self.assertIn("$mnemo:save", payload["reason"])
self.assertIn("$mnemo:session", payload["reason"])
self.assertIn("$mnemo:review --full", payload["reason"])

second = subprocess.run(
["bash", str(script)],
Expand Down
124 changes: 124 additions & 0 deletions scripts/test-stop-nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Regression tests for mnemo-stop-nudge.sh (v1.2.9).

Pins the v1.2.9 behavior:
1. The nudge recommends the ONE-command close-out `/mn:review --full`
(Codex: `$mnemo:review --full`), not save+session listed separately.
2. The anti-loop governor blocks at most once per session (marker keyed on
session_id; in Codex, where the Stop payload may omit session_id, it falls
back to CODEX_THREAD_ID so it dedups instead of re-nudging every Stop).
3. It stays silent unless worth-saving signals ≥3 AND save/session are missing.

Stdlib-only (unittest + subprocess), run directly:

python3 scripts/test-stop-nudge.py
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HOOK = os.path.join(REPO, "plugins", "mnemo", "hooks", "mnemo-stop-nudge.sh")

# 3 signal-bearing messages (SIGNAL_RE: fixed/resolved/decided/gotcha/...) → signals=3.
SIGNAL_TRANSCRIPT = "\n".join(
json.dumps({"message": {"role": "assistant", "content": [{"type": "text", "text": t}]}})
for t in ("we finally fixed the auth bug", "root cause resolved", "decided on Redis, gotcha noted")
) + "\n"

# Same 3 signals BUT with save + session skills invoked → both ran → no nudge.
BOTH_RAN_TRANSCRIPT = SIGNAL_TRANSCRIPT + "\n".join(
json.dumps({"message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": s}}]}})
for s in ("save", "session")
) + "\n"

# Below the worth-saving threshold (1 signal < 3) → silent.
LOW_SIGNAL_TRANSCRIPT = json.dumps(
{"message": {"role": "assistant", "content": [{"type": "text", "text": "just fixed a typo"}]}}
) + "\n"


def write(path, text):
with open(path, "w", encoding="utf-8") as f:
f.write(text)


class StopNudgeTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
# Isolated HOME so the gate reads OUR config, not the real user's.
home = self.tmp.name
os.makedirs(os.path.join(home, ".mnemo"), exist_ok=True)
write(os.path.join(home, ".mnemo", "config.json"), json.dumps({"hooks": {"stopNudge": True}}))
self.home = home
# Anti-loop markers live in a shared per-user cache keyed on session_id (NOT under
# HOME), so identical ids would collide across test runs. Derive a per-run-unique
# suffix from the temp dir name so each run uses fresh marker keys.
self.uniq = os.path.basename(home.rstrip("/"))
self.transcript = os.path.join(home, "t.jsonl")

def tearDown(self):
self.tmp.cleanup()

def run_hook(self, payload, codex=False, thread=None):
env = {k: v for k, v in os.environ.items() if k not in ("PLUGIN_ROOT", "CODEX_THREAD_ID", "CODEX_SESSION_ID")}
env["HOME"] = self.home
if codex:
env["CODEX_THREAD_ID"] = thread or "codex-thread-x"
res = subprocess.run(
["bash", HOOK], input=json.dumps(payload), capture_output=True, text=True, env=env, timeout=15
)
return res.stdout.strip()

def test_nudge_recommends_review_full_claude(self):
write(self.transcript, SIGNAL_TRANSCRIPT)
out = self.run_hook({"session_id": f"s-claude-{self.uniq}", "transcript_path": self.transcript, "stop_hook_active": False})
data = json.loads(out)
self.assertEqual(data.get("decision"), "block")
self.assertIn("/mn:review --full", data["reason"])
self.assertNotIn("/mn:save and /mn:session", data["reason"]) # no longer lists them separately

def test_nudge_uses_codex_syntax(self):
write(self.transcript, SIGNAL_TRANSCRIPT)
out = self.run_hook({"session_id": f"s-codex-{self.uniq}", "transcript_path": self.transcript, "stop_hook_active": False}, codex=True)
self.assertIn("$mnemo:review --full", json.loads(out)["reason"])

def test_blocks_at_most_once_per_session(self):
write(self.transcript, SIGNAL_TRANSCRIPT)
p = {"session_id": f"s-dedup-{self.uniq}", "transcript_path": self.transcript, "stop_hook_active": False}
first = json.loads(self.run_hook(p))
second = json.loads(self.run_hook(p))
self.assertEqual(first.get("decision"), "block")
self.assertTrue(second.get("continue")) # second Stop passes through
self.assertNotEqual(second.get("decision"), "block")

def test_codex_dedups_on_thread_when_session_id_missing(self):
"""The v1.2.9 fix: unstable/absent Codex session_id keys on CODEX_THREAD_ID."""
write(self.transcript, SIGNAL_TRANSCRIPT)
p = {"session_id": "unknown", "transcript_path": self.transcript, "stop_hook_active": False}
first = json.loads(self.run_hook(p, codex=True, thread=f"thread-{self.uniq}"))
second = json.loads(self.run_hook(p, codex=True, thread=f"thread-{self.uniq}"))
self.assertEqual(first.get("decision"), "block")
self.assertTrue(second.get("continue"))

def test_silent_below_signal_threshold(self):
write(self.transcript, LOW_SIGNAL_TRANSCRIPT)
out = json.loads(self.run_hook({"session_id": f"s-low-{self.uniq}", "transcript_path": self.transcript, "stop_hook_active": False}))
self.assertNotEqual(out.get("decision"), "block")

def test_silent_when_save_and_session_already_ran(self):
write(self.transcript, BOTH_RAN_TRANSCRIPT)
out = json.loads(self.run_hook({"session_id": f"s-both-{self.uniq}", "transcript_path": self.transcript, "stop_hook_active": False}))
self.assertNotEqual(out.get("decision"), "block")

def test_stop_hook_active_recursion_guard_passes(self):
write(self.transcript, SIGNAL_TRANSCRIPT)
out = json.loads(self.run_hook({"session_id": f"s-recur-{self.uniq}", "transcript_path": self.transcript, "stop_hook_active": True}))
self.assertNotEqual(out.get("decision"), "block")


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading