c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
491 lines
19 KiB
Bash
491 lines
19 KiB
Bash
# shellcheck shell=bash
|
|
# MEMPALACE CURSOR HOOK — shared helpers
|
|
#
|
|
# Sourced by the three Cursor hooks (stop / preCompact / sessionStart).
|
|
# Mirrors the conventions of the existing Claude Code hook scripts
|
|
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh) so a
|
|
# user who already debugs one knows how to debug the other:
|
|
#
|
|
# * STATE_DIR layout under ~/.mempalace/hook_state/
|
|
# * MEMPAL_PYTHON resolution order (override → $PATH → bare python3)
|
|
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
|
|
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
|
|
# * fail-open on internal errors: emit `{}` and log, never crash the
|
|
# hook host
|
|
#
|
|
# Cursor-specific additions on top of that contract:
|
|
#
|
|
# * MEMPAL_DISABLE_HOOK=1 as an additional kill-switch alias
|
|
# * MEMPAL_STATE_DIR env override for the state directory
|
|
# * conversation_id (Cursor's stable per-conversation ID) replaces
|
|
# Claude Code's session_id in the counter file names — Cursor `stop`
|
|
# events do not carry a session_id, only conversation_id
|
|
# * loop_count is the loop-prevention signal in place of Claude Code's
|
|
# stop_hook_active flag (Cursor docs, "stop" event)
|
|
#
|
|
# This file is sourced, not executed, so it intentionally has no
|
|
# shebang. The `# shellcheck shell=bash` directive above tells
|
|
# shellcheck to treat it as bash when run standalone.
|
|
|
|
# ── State directory + log path ────────────────────────────────────────
|
|
#
|
|
# Honour MEMPAL_STATE_DIR (additive override introduced for Cursor)
|
|
# while keeping the default identical to the Claude Code hooks so a
|
|
# user running both keeps a single state directory.
|
|
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
|
|
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
|
|
MEMPAL_CURSOR_LOG="$MEMPAL_STATE_DIR/cursor_hook.log"
|
|
|
|
# ── Python interpreter resolution ─────────────────────────────────────
|
|
#
|
|
# Same contract as the Claude Code hooks:
|
|
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
|
|
# 2. $(command -v python3) — first python3 on the hook's PATH
|
|
# 3. bare "python3" — last-resort fallback
|
|
mempal_resolve_python() {
|
|
local p="${MEMPAL_PYTHON:-}"
|
|
if [ -n "$p" ] && [ -x "$p" ]; then
|
|
printf '%s' "$p"
|
|
return 0
|
|
fi
|
|
p="$(command -v python3 2>/dev/null || true)"
|
|
if [ -n "$p" ]; then
|
|
printf '%s' "$p"
|
|
return 0
|
|
fi
|
|
printf '%s' "python3"
|
|
}
|
|
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
|
|
|
|
# ── Logging ───────────────────────────────────────────────────────────
|
|
#
|
|
# Lines are `[ISO8601Z] [event=...] [conv=...] message`. ISO8601 keeps
|
|
# the format greppable across timezones (the Claude Code log uses
|
|
# %H:%M:%S which loses the date — we improve on that here without
|
|
# changing the existing log file).
|
|
mempal_log() {
|
|
local event="${1:-?}"
|
|
local conv="${2:-unknown}"
|
|
local msg="${3:-}"
|
|
local ts
|
|
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
|
|
>> "$MEMPAL_CURSOR_LOG" 2>/dev/null
|
|
}
|
|
|
|
# ── Kill switch ───────────────────────────────────────────────────────
|
|
#
|
|
# Disabled if ANY of:
|
|
# * MEMPAL_DISABLE_HOOK is a truthy string (Cursor-prompt addition)
|
|
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no (Claude Code convention)
|
|
# * ~/.mempalace/config.json has hooks.auto_save == false
|
|
#
|
|
# Returns 0 (true in shell) when disabled, 1 when enabled.
|
|
mempal_is_disabled() {
|
|
case "${MEMPAL_DISABLE_HOOK:-}" in
|
|
1|true|yes|on) return 0 ;;
|
|
esac
|
|
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
|
|
false|0|no|off) return 0 ;;
|
|
esac
|
|
local cfg="$HOME/.mempalace/config.json"
|
|
if [ -f "$cfg" ]; then
|
|
local result
|
|
# Use python -c '...' with the config path as argv[1] rather
|
|
# than a heredoc. A heredoc body that contains parens inside a
|
|
# $(...) command substitution trips the bash 3.2.57 parser
|
|
# bug (macOS /bin/bash default) — gh-PR review caught this.
|
|
# The -c form is also consistent with mempal_parse_stdin below.
|
|
result="$("$MEMPAL_PYTHON_BIN" -c '
|
|
import json, sys
|
|
try:
|
|
with open(sys.argv[1]) as f:
|
|
cfg = json.load(f)
|
|
print(str(cfg.get("hooks", {}).get("auto_save", True)).lower())
|
|
except Exception:
|
|
print("true")
|
|
' "$cfg" 2>/dev/null)"
|
|
if [ "$result" = "false" ]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# ── Stdin parser ──────────────────────────────────────────────────────
|
|
#
|
|
# Reads Cursor's hook JSON from $1 and exports:
|
|
# MEMPAL_CONV_ID — conversation_id, falls back to "unknown"
|
|
# MEMPAL_LOOP_COUNT — integer (0 if absent / non-numeric)
|
|
# MEMPAL_TRANSCRIPT — transcript_path, may be empty
|
|
# MEMPAL_WORKSPACE — first workspace_roots entry, falls back to
|
|
# CURSOR_PROJECT_DIR env var, then $PWD
|
|
# MEMPAL_TRIGGER — preCompact trigger ("auto" | "manual"), empty otherwise
|
|
# MEMPAL_STATUS — stop status ("completed" | "aborted" | "error"),
|
|
# empty otherwise
|
|
# MEMPAL_PARSE_OK — "1" if parser ran cleanly, "0" otherwise
|
|
#
|
|
# Uses the same sentinel + `sed -n 'Np'` extraction as the Claude Code
|
|
# hooks for bash 3.2 compatibility (mapfile/readarray are unavailable
|
|
# on macOS /bin/bash 3.2.57; #1440 regression). Each line of output is
|
|
# pre-sanitised by the Python side to a shell-safe character set.
|
|
mempal_parse_stdin() {
|
|
local input="${1:-}"
|
|
local parsed
|
|
# We invoke Python via -c with a single-quoted multi-line string
|
|
# rather than ``python3 - <<'PYEOF'`` because the heredoc form
|
|
# would shadow Python's stdin with the heredoc body, leaving
|
|
# ``json.load(sys.stdin)`` to read nothing and silently fail. The
|
|
# parser body deliberately uses only double-quoted Python strings
|
|
# so the surrounding bash single-quote is safe verbatim, and uses
|
|
# only the shell-safe character set (alphanumeric, underscore,
|
|
# dash, slash, dot, tilde) matching the Claude Code hook's
|
|
# sanitiser so a hostile transcript_path cannot splice
|
|
# metacharacters into the parsed output.
|
|
parsed="$(
|
|
umask 077
|
|
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c '
|
|
import json, re, sys
|
|
|
|
def safe_str(value):
|
|
return re.sub(r"[^a-zA-Z0-9_/.\-~]", "", str(value or ""))
|
|
|
|
def safe_int(value):
|
|
try:
|
|
return str(int(value))
|
|
except (TypeError, ValueError):
|
|
return "0"
|
|
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
except Exception:
|
|
sys.exit(1)
|
|
if not isinstance(data, dict):
|
|
sys.exit(1)
|
|
|
|
conv = safe_str(data.get("conversation_id") or data.get("session_id"))
|
|
loop_count = safe_int(data.get("loop_count", 0))
|
|
transcript = safe_str(data.get("transcript_path", ""))
|
|
trigger = safe_str(data.get("trigger", ""))
|
|
status = safe_str(data.get("status", ""))
|
|
|
|
roots = data.get("workspace_roots") or []
|
|
workspace = ""
|
|
if isinstance(roots, list) and roots:
|
|
workspace = safe_str(roots[0])
|
|
|
|
print("__MEMPAL_PARSE_OK__")
|
|
print(conv)
|
|
print(loop_count)
|
|
print(transcript)
|
|
print(workspace)
|
|
print(trigger)
|
|
print(status)
|
|
' 2>"$MEMPAL_STATE_DIR/cursor_last_python_err.log"
|
|
)"
|
|
|
|
# Drop empty stderr capture on success; lock it to 0600 on failure
|
|
# (mirrors the privacy contract in the Claude Code hooks — the
|
|
# traceback can echo back transcript_path / home layout).
|
|
if [ -s "$MEMPAL_STATE_DIR/cursor_last_python_err.log" ]; then
|
|
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
|
|
else
|
|
rm -f "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
|
|
fi
|
|
|
|
local marker
|
|
marker="$(printf '%s\n' "$parsed" | sed -n '1p')"
|
|
if [ "$marker" = "__MEMPAL_PARSE_OK__" ]; then
|
|
MEMPAL_PARSE_OK="1"
|
|
MEMPAL_CONV_ID="$(printf '%s\n' "$parsed" | sed -n '2p')"
|
|
MEMPAL_LOOP_COUNT="$(printf '%s\n' "$parsed" | sed -n '3p')"
|
|
MEMPAL_TRANSCRIPT="$(printf '%s\n' "$parsed" | sed -n '4p')"
|
|
MEMPAL_WORKSPACE="$(printf '%s\n' "$parsed" | sed -n '5p')"
|
|
MEMPAL_TRIGGER="$(printf '%s\n' "$parsed" | sed -n '6p')"
|
|
MEMPAL_STATUS="$(printf '%s\n' "$parsed" | sed -n '7p')"
|
|
else
|
|
MEMPAL_PARSE_OK="0"
|
|
MEMPAL_CONV_ID=""
|
|
MEMPAL_LOOP_COUNT="0"
|
|
MEMPAL_TRANSCRIPT=""
|
|
MEMPAL_WORKSPACE=""
|
|
MEMPAL_TRIGGER=""
|
|
MEMPAL_STATUS=""
|
|
fi
|
|
|
|
# Defaults and environment fallbacks. The Cursor docs guarantee
|
|
# CURSOR_TRANSCRIPT_PATH and CURSOR_PROJECT_DIR env vars are set
|
|
# for every hook execution; if JSON parsing failed for any reason
|
|
# (sentinel missing, malformed payload, missing interpreter) we
|
|
# still have a usable workspace.
|
|
MEMPAL_CONV_ID="${MEMPAL_CONV_ID:-unknown}"
|
|
case "$MEMPAL_LOOP_COUNT" in
|
|
''|*[!0-9]*) MEMPAL_LOOP_COUNT="0" ;;
|
|
esac
|
|
if [ -z "$MEMPAL_TRANSCRIPT" ] && [ -n "${CURSOR_TRANSCRIPT_PATH:-}" ]; then
|
|
MEMPAL_TRANSCRIPT="${CURSOR_TRANSCRIPT_PATH}"
|
|
fi
|
|
if [ -z "$MEMPAL_WORKSPACE" ]; then
|
|
if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then
|
|
MEMPAL_WORKSPACE="${CURSOR_PROJECT_DIR}"
|
|
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
MEMPAL_WORKSPACE="${CLAUDE_PROJECT_DIR}"
|
|
else
|
|
MEMPAL_WORKSPACE="${PWD:-/}"
|
|
fi
|
|
fi
|
|
|
|
# Expand a leading ~ in the transcript path so downstream
|
|
# ``[ -f "$path" ]`` checks resolve correctly.
|
|
case "$MEMPAL_TRANSCRIPT" in
|
|
'~/'*) MEMPAL_TRANSCRIPT="$HOME/${MEMPAL_TRANSCRIPT#~/}" ;;
|
|
esac
|
|
}
|
|
|
|
# ── Defense-in-depth: dump unparseable stdin ──────────────────────────
|
|
#
|
|
# Same shape as the Claude Code hooks' last_input.log: bounded to 4096
|
|
# bytes, overwritten (never appended) so a misconfiguration loop cannot
|
|
# grow disk usage, 0600 perms because the dump mirrors the raw hook
|
|
# payload (transcript_path reveals the user's home + project layout).
|
|
mempal_dump_bad_input() {
|
|
local input="${1:-}"
|
|
if [ -z "$input" ]; then
|
|
return 0
|
|
fi
|
|
mempal_log "${2:-?}" "${MEMPAL_CONV_ID:-unknown}" \
|
|
"WARN: input parse failed (sentinel missing); see $MEMPAL_STATE_DIR/cursor_last_input.log + cursor_last_python_err.log"
|
|
(
|
|
umask 077
|
|
printf '%s' "$input" | head -c 4096 > "$MEMPAL_STATE_DIR/cursor_last_input.log"
|
|
)
|
|
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_input.log" 2>/dev/null
|
|
}
|
|
|
|
# ── Counter helpers ───────────────────────────────────────────────────
|
|
#
|
|
# One counter file per conversation_id. Atomic write via temp file
|
|
# inside the same directory (rename is atomic on POSIX) so concurrent
|
|
# hook invocations cannot half-write the file. Read tolerates a
|
|
# corrupted or empty file by returning 0, never crashing.
|
|
_mempal_counter_path() {
|
|
local conv="${1:-unknown}"
|
|
# Sanitise the conv id one more time: it has already been through
|
|
# the Python sanitiser, but be defensive in case a caller passes a
|
|
# raw string. Strip any character outside [a-zA-Z0-9_.-].
|
|
local safe_conv
|
|
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
|
|
if [ -z "$safe_conv" ]; then
|
|
safe_conv="unknown"
|
|
fi
|
|
printf '%s/cursor_%s.count' "$MEMPAL_STATE_DIR" "$safe_conv"
|
|
}
|
|
|
|
mempal_read_counter() {
|
|
local path="$1"
|
|
if [ ! -f "$path" ]; then
|
|
printf '0'
|
|
return 0
|
|
fi
|
|
local raw
|
|
raw="$(cat "$path" 2>/dev/null)"
|
|
case "$raw" in
|
|
''|*[!0-9]*) printf '0' ;;
|
|
*) printf '%s' "$raw" ;;
|
|
esac
|
|
}
|
|
|
|
mempal_write_counter_atomic() {
|
|
local path="$1"
|
|
local value="$2"
|
|
case "$value" in
|
|
''|*[!0-9]*) value="0" ;;
|
|
esac
|
|
local tmp="${path}.tmp.$$"
|
|
printf '%s' "$value" > "$tmp" 2>/dev/null || return 1
|
|
mv "$tmp" "$path" 2>/dev/null || {
|
|
rm -f "$tmp" 2>/dev/null
|
|
return 1
|
|
}
|
|
}
|
|
|
|
# ── Pending-save marker ───────────────────────────────────────────────
|
|
#
|
|
# Dropped by the preCompact hook (which cannot itself emit a
|
|
# followup_message — Cursor's preCompact is observational-only) and
|
|
# consumed by the next stop invocation so the LLM still gets a diary
|
|
# nudge after compaction.
|
|
_mempal_pending_path() {
|
|
local conv="${1:-unknown}"
|
|
local safe_conv
|
|
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
|
|
if [ -z "$safe_conv" ]; then
|
|
safe_conv="unknown"
|
|
fi
|
|
printf '%s/cursor_%s.pending' "$MEMPAL_STATE_DIR" "$safe_conv"
|
|
}
|
|
|
|
mempal_set_pending() {
|
|
local conv="${1:-unknown}"
|
|
local path
|
|
path="$(_mempal_pending_path "$conv")"
|
|
: > "$path" 2>/dev/null || return 1
|
|
chmod 600 "$path" 2>/dev/null
|
|
}
|
|
|
|
mempal_consume_pending() {
|
|
local conv="${1:-unknown}"
|
|
local path
|
|
path="$(_mempal_pending_path "$conv")"
|
|
if [ -f "$path" ]; then
|
|
rm -f "$path" 2>/dev/null
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# ── State-file TTL ────────────────────────────────────────────────────
|
|
#
|
|
# Per-conversation state artifacts (cursor_<conv>.count and
|
|
# cursor_<conv>.pending) accumulate one set per conversation and are
|
|
# never otherwise removed (igorls review, PR #1632 — unbounded state
|
|
# growth). Reads MEMPAL_STATE_TTL_DAYS (default 30), validated
|
|
# digits-only and leading-zero-stripped (mirrors the SAVE_INTERVAL
|
|
# sanitiser) so `find -mtime` never sees a bad or octal token. Empty or
|
|
# non-numeric floors to 30; a value of 0 means "sweep everything older
|
|
# than today".
|
|
mempal_state_ttl_days() {
|
|
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
|
|
case "$raw" in
|
|
''|*[!0-9]*) printf '30'; return 0 ;;
|
|
esac
|
|
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
|
raw="${raw#0}"
|
|
done
|
|
printf '%s' "$raw"
|
|
}
|
|
|
|
# ── Stale state GC ────────────────────────────────────────────────────
|
|
#
|
|
# Opportunistic sweep of per-conversation Cursor state older than the
|
|
# TTL. Throttled to at most once per 24h via the cursor_last_sweep
|
|
# marker, so it costs a single mtime comparison on the vast majority of
|
|
# fires. When it does run, two `find` passes remove the stale counter
|
|
# files and pending markers.
|
|
#
|
|
# The globs are Cursor-specific and suffix-anchored (cursor_*.count,
|
|
# cursor_*.pending), so the shared logs (cursor_hook.log,
|
|
# cursor_last_input.log, cursor_last_python_err.log), the
|
|
# cursor_last_sweep marker itself, and any antigravity_*/Claude state
|
|
# sharing the same directory are never touched. BSD find (macOS default)
|
|
# and GNU find both accept -maxdepth, -mtime +N, and -exec ... +.
|
|
#
|
|
# Fail-open: every step is best-effort; a missing state dir, a find that
|
|
# errors, or a permission problem must never abort the caller.
|
|
mempal_gc_stale_state() {
|
|
[ -d "$MEMPAL_STATE_DIR" ] || return 0
|
|
|
|
local marker="$MEMPAL_STATE_DIR/cursor_last_sweep"
|
|
if [ -f "$marker" ]; then
|
|
local mtime now
|
|
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
|
|
&& now=$(date '+%s' 2>/dev/null) \
|
|
&& [ -n "$mtime" ] \
|
|
&& [ "$((now - mtime))" -lt 86400 ]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
# Touch the marker first so a crash mid-sweep still throttles the
|
|
# next fire (better to skip a sweep than to hammer the disk).
|
|
: > "$marker" 2>/dev/null
|
|
|
|
local ttl
|
|
ttl=$(mempal_state_ttl_days)
|
|
|
|
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
|
-name 'cursor_*.count' -mtime +"$ttl" \
|
|
-exec rm -f {} + 2>/dev/null
|
|
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
|
-name 'cursor_*.pending' -mtime +"$ttl" \
|
|
-exec rm -f {} + 2>/dev/null
|
|
|
|
return 0
|
|
}
|
|
|
|
# ── Workspace → wing inference ────────────────────────────────────────
|
|
#
|
|
# basename(workspace_root), normalised to [a-z0-9_-]. Edge cases:
|
|
# / → "root"
|
|
# /path/ → trailing slash stripped, then basename
|
|
# "/foo bar/" → "foo_bar" (spaces collapsed to underscores)
|
|
# "" → "cursor_session"
|
|
# "C:\\proj" → "proj" (Windows-style path; basename via tr fallback)
|
|
#
|
|
# We intentionally keep this in pure bash + POSIX tools so the
|
|
# inference is identical across the hook scripts and the test suite
|
|
# can target it as a function via `bash -c 'source ...; ...'`.
|
|
mempal_infer_wing() {
|
|
local raw="${1:-}"
|
|
if [ -z "$raw" ]; then
|
|
printf 'cursor_session'
|
|
return 0
|
|
fi
|
|
# Strip trailing slashes (but preserve the lone "/" case).
|
|
while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do
|
|
raw="${raw%/}"
|
|
done
|
|
if [ "$raw" = "/" ]; then
|
|
printf 'root'
|
|
return 0
|
|
fi
|
|
local base="${raw##*/}"
|
|
# On Windows-style paths with backslashes, fall back to splitting
|
|
# on backslash too so we don't return the whole path verbatim.
|
|
case "$base" in
|
|
*\\*) base="${base##*\\}" ;;
|
|
esac
|
|
# Lowercase + replace anything outside [a-z0-9_-] with underscore.
|
|
# Collapse runs of underscores so "foo bar" doesn't become
|
|
# "foo__bar".
|
|
base="$(printf '%s' "$base" \
|
|
| tr '[:upper:]' '[:lower:]' \
|
|
| tr -c 'a-z0-9_-' '_' \
|
|
| tr -s '_' \
|
|
| sed 's/^_//; s/_$//')"
|
|
if [ -z "$base" ]; then
|
|
printf 'cursor_session'
|
|
return 0
|
|
fi
|
|
printf '%s' "$base"
|
|
}
|
|
|
|
# ── Transcript path validation ────────────────────────────────────────
|
|
#
|
|
# Mirrors hooks/mempal_save_hook.sh::is_valid_transcript_path so the
|
|
# Cursor and Claude Code hooks reject the same shapes:
|
|
# * non-empty
|
|
# * .json or .jsonl suffix
|
|
# * no .. traversal segments
|
|
mempal_is_valid_transcript() {
|
|
local path="${1:-}"
|
|
[ -n "$path" ] || return 1
|
|
case "$path" in
|
|
*.json|*.jsonl) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
case "/$path/" in
|
|
*/../*) return 1 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# ── JSON emit ─────────────────────────────────────────────────────────
|
|
#
|
|
# Final stdout write. Uses ``printf '%s'`` instead of ``echo`` because
|
|
# echo interprets ``-n``/``-e``/``-E`` as flags and varies in backslash
|
|
# handling between builtin and /bin/echo (xpg_echo shopt). Matches the
|
|
# Claude Code hook's documented rationale.
|
|
mempal_emit() {
|
|
printf '%s\n' "${1:-{\}}"
|
|
}
|