From c9e385072d6228e30feefac2490f8bf352b82bd3 Mon Sep 17 00:00:00 2001 From: jojoprison Date: Tue, 21 Jul 2026 02:27:16 +0700 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20Stop-=D0=BD=D1=83=D0=B4=D0=B6=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D1=83=D0=B5?= =?UTF-8?q?=D1=82=20review=20--full=20+=20=D1=84=D0=B8=D0=BA=D1=81=20Codex?= =?UTF-8?q?-=D1=87=D0=B0=D1=81=D1=82=D0=BE=D1=82=D1=8B=20(v1.2.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop-нудж теперь советует ОДНУ команду закрытия `/mn:review --full` (save+session+connect+verify) вместо перечисления save и session по отдельности — консистентно с v1.2.8. Гейтинг не тронут (opt-in hooks.stopNudge, сигналы ≥3, save/session не вызывались, раз за сессию). Fixed: нудж фонил в Codex — анти-луп маркер ключевался только на session_id из Stop-payload, который Codex может не давать/менять → дедуп не работал, бил на каждый Stop. Теперь fallback на CODEX_THREAD_ID/CODEX_SESSION_ID (стабильный per-thread ключ). Новый scripts/test-stop-nudge.py (7 кейсов): --full-рекомендация, обе формы синтаксиса, дедуп раз-за-сессию, Codex thread-key fallback, условия молчания. test-runtime-compat обновлён под новое сообщение. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 13 ++- docs/design-decisions.md | 2 +- plugins/mnemo/.claude-plugin/plugin.json | 2 +- plugins/mnemo/.codex-plugin/plugin.json | 2 +- plugins/mnemo/hooks/mnemo-stop-nudge.sh | 23 +++-- scripts/test-runtime-compat.py | 3 +- scripts/test-stop-nudge.py | 124 +++++++++++++++++++++++ 8 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 scripts/test-stop-nudge.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 19bbfcc..b0ffea5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c45ff..569b4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 8d3755c..52ae687 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -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: (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. diff --git a/plugins/mnemo/.claude-plugin/plugin.json b/plugins/mnemo/.claude-plugin/plugin.json index ca1889b..9e7e745 100644 --- a/plugins/mnemo/.claude-plugin/plugin.json +++ b/plugins/mnemo/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/mnemo/.codex-plugin/plugin.json b/plugins/mnemo/.codex-plugin/plugin.json index 16e01a9..c060710 100644 --- a/plugins/mnemo/.codex-plugin/plugin.json +++ b/plugins/mnemo/.codex-plugin/plugin.json @@ -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", diff --git a/plugins/mnemo/hooks/mnemo-stop-nudge.sh b/plugins/mnemo/hooks/mnemo-stop-nudge.sh index 85f3715..b12ba84 100755 --- a/plugins/mnemo/hooks/mnemo-stop-nudge.sh +++ b/plugins/mnemo/hooks/mnemo-stop-nudge.sh @@ -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 @@ -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 @@ -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" diff --git a/scripts/test-runtime-compat.py b/scripts/test-runtime-compat.py index 8ff5320..4fb2b4b 100644 --- a/scripts/test-runtime-compat.py +++ b/scripts/test-runtime-compat.py @@ -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)], diff --git a/scripts/test-stop-nudge.py b/scripts/test-stop-nudge.py new file mode 100644 index 0000000..33beb70 --- /dev/null +++ b/scripts/test-stop-nudge.py @@ -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)