#!/bin/bash
# memory-checkpoint-reminder.sh — PreToolUse hook (all tools)
# Reminds agents to save a memory checkpoint when 50+ tool calls
# have occurred since the last memory save.
#
# Fires on: PreToolUse (empty matcher — all tool calls)
# Input: JSON on stdin with tool_name and tool_input
# Output: reminder message on stderr (fd 3) when counter >= 50 — a required-save
#   demand for a MAIN-THREAD session observed to have a memory-write tool, otherwise a
#   once-per-session advisory that demands nothing (#4863, see CAPABILITY below;
#   #5136, see DISPATCHED SUBAGENTS below — a subagent is never given the demand)
# Exit: 2 when either message is emitted, 0 otherwise.
#   The previous header here read "always 0 (never blocks tool calls)". That has
#   not matched the code since the reminder moved to exit 2 (see the exit at the
#   end of section 3), which is what makes a PreToolUse message visible to the
#   agent at all. Corrected rather than "fixed" back to 0: exit 2 is the intended
#   behaviour, and a header asserting a safety property the code does not have is
#   the same defect class as the misattributed reminder #4734 addresses — in a
#   file that runs on EVERY tool call for every agent.
#
# DISPATCHED SUBAGENTS (#5136). A dispatched subagent is never given the demand. It
# gets the advisory branch instead, on the same threshold, worded for its situation.
#
# The defect this closes: capability was being read as AUTHORITY. Having used Bash
# marks a session CAPABLE, and CAPABLE selected the demanding branch — which also
# DROPPED the advisory's "checkpointing is the dispatching session's responsibility"
# line, at exactly the sessions that most needed it. A subagent that runs one Bash
# call is capable of writing a memory and is still the wrong party to write one: its
# brief carries no conversationId, and it has no synchronous channel to ask its
# dispatcher for one. On 2026-08-07 this hook demanded a checkpoint from four
# dispatched subagents in one day. One complied with --conversation-id NEW, which
# opened a fresh conversation per checkpoint and split one iteration's record across
# several; three declined and flagged the omission. Both outcomes are costs the hook
# created. Being able to perform an action is not the same as being the party
# responsible for it, and a demand the receiving session cannot correctly satisfy is
# the #4863 failure mode in a new place.
#
# THE SIGNAL IS .agent_id, and it is authoritative, not inferred. Two sources, both
# checkable, quoted verbatim rather than summarised — this file's whole discipline is
# that a claim in a comment can be verified against the thing it describes.
#
#   1. The published hook reference, https://code.claude.com/docs/en/hooks, hook input
#      fields table (checked 2026-08-07):
#        agent_id   — "Unique identifier for the subagent. Present only when the hook
#                      fires inside a subagent call. Use this to distinguish subagent
#                      hook calls from main-thread calls."
#        agent_type — "Agent name (for example, "Explore" or "security-reviewer").
#                      Present when the session uses --agent or the hook fires inside a
#                      subagent. For subagents, the subagent's type takes precedence
#                      over the session's --agent value. See SubagentStart for the
#                      values custom and plugin subagents report and how to write a
#                      matcher against a plugin-scoped name."
#      Both rows are the complete cell, to the end. The agent_type cell's last sentence
#      is a cross-reference this hook does not use, and an earlier revision dropped it
#      as irrelevant without marking the elision — in a block claiming verbatim
#      quotation, which makes the judgement invisible to the reader checking it. Quoted
#      whole instead of elided, so there is no judgement left to check.
#   2. The runtime's own payload schema, which is stricter about the main-thread case.
#      In the installed bundle (checked against version 2.1.224, file
#      ~/.local/share/claude/versions/2.1.224, the zod .describe() attached to
#      agent_id) it reads: "Subagent identifier. Present only when the hook fires from
#      within a subagent (e.g., a tool called by an AgentTool worker). Absent for the
#      main thread, even in --agent sessions. Use this field (not agent_type) to
#      distinguish subagent calls from main-thread calls."
#
# Source 2 is a version-pinned string inside a shipped binary, so it can change without
# notice and is cited as corroboration, not as the contract; source 1 is the contract.
# They agree on the load-bearing point, which is why .agent_id is read and .agent_type
# is deliberately NOT: agent_type is ALSO set on the main thread of an --agent session,
# so branching on it would silence the demand for a legitimate lead. A test pins that.
#
# (An earlier revision of this block presented source 2's wording as though it were the
# published documentation. The semantics were right and the string was real, but a
# reader checking it against the docs would not find those sentences — which is exactly
# the misattribution failure #4734 is about, committed in a comment instead of a
# message. Recorded rather than quietly deleted.)
#
# COUNTING SCOPE (#4802, extended by #5136). The counter is keyed by SESSION
# (harness-supplied .session_id) AND, inside a subagent, by .agent_id.
#
# Session alone was not enough, and this is the STALE-COUNTER root cause (#5136): a
# dispatched subagent runs under its DISPATCHER'S session_id. Verified in the fleet's
# own transcripts — a subagent's records live at
# <projectDir>/<sessionId>/subagents/agent-<agentId>.jsonl and every line in the child
# file carries the parent's sessionId. So one leader plus its concurrent subagents all
# shared ONE per-session counter: the lead's. A subagent's FIRST tool call read a
# counter already at 49 and was told "50 tool calls since your last memory save" about
# work it had not done. That is the same false premise #5013 removed from the
# worktree-wide fallback key, still alive one level down, because #4802 keyed on the
# session and a session is not one agent. Adding .agent_id to the key makes the count
# a fact about the agent being handed it, which is the whole property #4802 was after.
#
# The original #4802 rationale, unchanged and still the reason the session component
# exists: the key used to be the worktree alone, which made this guard resolve its subject
# from ambient context rather than from the agent it was actually guarding: a
# session that had made two tool calls itself was told "50 tool calls since last
# memory save" and required to write a checkpoint because OTHER sessions in the
# worktree were busy. A read-only auditor was forced to write durable memory purely
# to unblock itself. #4758 established that .session_id is on the payload and how to
# key state with it; this hook now uses it too. The discipline is unchanged — same
# threshold, same required save — it is the attribution that is fixed.
#
# When the payload is readable and the harness supplied NO session id, this hook now
# EXITS SILENTLY: it does not count, does not observe capability, and does not remind.
# When the payload is unreadable (broken jq) the legacy worktree-wide key is still
# used, per #4863 — see NO SESSION ID → NO REMINDER below for why the two differ.
#
# NO SESSION ID → NO REMINDER (#5013). This reverses what this comment used to say.
# The old text read: "the counter falls back to the legacy worktree-wide key. That
# over-counts rather than under-counts, which keeps the reminder firing instead of
# silently disabling it." That preference was wrong, and it was wrong in a way that
# only shows up at the concurrency this fleet actually runs at.
#
# The worktree-wide fallback key is SHARED BY EVERY AGENT IN THE WORKTREE. Under one
# leader plus three or more concurrent subagents, each making dozens of tool calls,
# that one file accumulates all of their calls. Any agent reading it — including a
# subagent on its FIRST tool call — is told "50 tool calls since last memory save"
# about work it did not do. That is not over-counting. It is a FALSE PREMISE: the
# sentence the agent is handed is not true of the agent it is handed to. #4802 fixed
# exactly this defect for the primary key and left it standing in the fallback.
#
# Why silence is the cheaper failure. A missed reminder costs one un-prompted
# checkpoint, and the agent's own roleguide already tells it when to checkpoint. A
# false reminder costs compliance with EVERY hook prompt on the fleet: an agent that
# correctly reasons "this demand's premise is false, I decline it" — which is the
# #4734 discipline working as designed — has just rehearsed the reasoning that lets
# it decline a TRUE demand. A control that cries wolf does not degrade only itself.
# It also makes a genuine hook message indistinguishable from an injected one
# (#4863/#5006), because "the claim in the message does not match reality" is the
# main test an agent has for spotting injected content.
#
# This is the one degradation in this file that points at silence rather than at
# over-reporting, and it is deliberately narrow: it applies ONLY when the payload
# parsed and the session id was absent from it. Every other degradation here picks
# between two TRUE messages, so over-reporting is the safe direction for them; this
# one picks between silence and a FALSE message, where it is not. The gate is a
# discipline aid, not a safety control — nothing is left unprotected while it is
# quiet — so it is allowed to fail off. No other gate in this file may adopt this
# pattern by analogy, and the broken-jq path deliberately does not (see the guard).
#
# CAPABILITY OBSERVATION (#4863). This hook is registered with an empty matcher, so
# it fires in EVERY session — including subagents provisioned with Read/Grep/Glob and
# no Bash and no MCP server. Those sessions were handed a demand ("required before
# continuing: mx memories create") they had no tool to satisfy, mid-tool-call, shaped
# exactly like the injected content #4697/#4734 taught agents to distrust. Product's
# bar-raiser hit this on 2026-08-02 and correctly reported it as a suspected injection.
#
# The hook event carries no toolset: PreToolUse gives session_id, tool_name, tool_input
# and transcript_path, none of which enumerate what the session may call. So capability
# is not asserted — it is OBSERVED, from the only evidence this hook actually has: the
# tool calls it sees. A session that has used Bash can run the CLI; a session that has
# used a MemNexus MCP tool has the MCP server connected and can call create_memory.
# Either observation marks the session capable (a marker file beside its counter) and
# the demand is unchanged for it.
#
# Capability selects between the two messages. It does NOT establish that the session is
# the right party to save (#5136): a dispatched subagent takes the advisory branch even
# when it has been observed using Bash, because the question the demand asks — "will you
# write this checkpoint?" — has a different answer from "could you?".
#
# A session with NO such observation after 50 tool calls gets an advisory instead: same
# information, no demand, and an explicit "no action required if you cannot save" —
# emitted ONCE per session, because repeating a no-action notice to a session that
# provably cannot act is pure interruption. Absence of observation is not proof of
# absence of capability, which is exactly why the advisory branch downgrades the wording
# instead of going silent. If the session later uses Bash or an MCP memory tool, it is
# marked capable and the normal demand resumes.
#
# Both messages carry a provenance block naming this hook's file path and trigger, per
# #4734: a receiving agent can check the claim against the repo instead of guessing
# whether an interruption is genuine.
#
# Without a session id, capability degrades to the worktree-wide key along with the
# counter — the same known #4802 limitation, in the over-report direction. The
# advisory's quiet period does NOT degrade with it: suppression requires a real
# per-session key, because suppression is the one outcome that cannot be recovered
# from by the agent receiving it. Likewise, a payload this hook cannot parse counts
# as capable, not as read-only, so a broken jq degrades to the demand rather than
# turning the gate off (see PAYLOAD_OPAQUE below).

MXHT_HOOK=memory-checkpoint-reminder MXHT_EVENT=PreToolUse MXHT_TOOL=-
# >>> SHARED hook-timing probe v1
# Diagnostic instrumentation: records how long THIS hook ran, so hook time can be
# separated from time spent waiting on the API when a session stalls mid-stream.
# Appends one line to a local, gitignored log. Read it with
# mx-agent-system/scripts/hook-timing-report.sh. Nothing is transmitted anywhere.
#
# FAIL SILENT IS DELIBERATE HERE, and it is the opposite of this repo's usual
# fail-loud direction. Do not "correct" it to fail-loud by applying the fleet
# default. A diagnostic that can break the gate it observes is worse than no
# diagnostic: an unwritable path or a full disk would otherwise take out every
# guard on the PreToolUse path at once. Every step below is guarded and every
# failure path is a silent no-op that leaves the hook's behaviour untouched.
#
# NEAR-ZERO OVERHEAD: bash builtins only on the hot path ($EPOCHREALTIME, printf
# -T, one read, one append). No subprocess, no network, no JSON parsing, and the
# hook's stdin is never touched (the hook owns it). mkdir runs at most once per
# worktree. Kill switch: MX_HOOK_TIMING=0.
MXHT_T0="${EPOCHREALTIME:-}"
_mxht_emit() {
  [ -z "${MXHT_DONE:-}" ] || return 0        # an ERR trap that exits fires EXIT too
  MXHT_DONE=1
  [ "${MX_HOOK_TIMING:-1}" != "0" ] || return 0
  [ -n "${MXHT_T0:-}" ] && [ -n "${EPOCHREALTIME:-}" ] || return 0
  local root="${CLAUDE_PROJECT_DIR:-${CLAUDE_WORKTREE_PATH:-}}"
  [ -n "$root" ] && [ -d "$root" ] || return 0
  local dir="$root/.mx-hook-timing"
  [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || return 0
  # EPOCHREALTIME is seconds.microseconds; the separator is locale-dependent, so
  # strip both forms and subtract as plain integer microseconds.
  local dur=-1
  { dur=$(( ${EPOCHREALTIME//[.,]/} - ${MXHT_T0//[.,]/} )); } 2>/dev/null
  [ "$dur" -ge 0 ] 2>/dev/null || return 0
  local stamp hour
  printf -v stamp '%(%Y-%m-%d %H:%M:%S)T' -1
  printf -v hour '%(%H)T' -1
  # BOUNDED BY CONSTRUCTION: a 24-slot ring, one file per hour-of-day, so the log
  # is at most 24 files each holding one hour of this machine's hook calls. The
  # first writer into a slot on a new day truncates it instead of appending —
  # detected by reading the slot's first line, which is a builtin read (no stat,
  # no rm, no find, no cron). Two writers can race that truncation and lose a
  # line or two at an hour boundary; that is acceptable for a sampling diagnostic.
  local f="$dir/h$hour.log" first="" line
  # THE SLOT MUST BE AN ORDINARY FILE, OR NOT EXIST YET (security review, #4848).
  # Anything else is a silent no-op. Two concrete failures if this check is
  # dropped: a FIFO at the slot makes the `read` below block until the hook's
  # settings.json timeout kills it, and a PreToolUse guard killed that way loses
  # its exit 2 — the command it meant to block then runs; a symlink at the slot
  # sends the day-rollover truncation onto the symlink's target. The `-e || -L`
  # wrapper is what lets a not-yet-created slot through (-e is false for a
  # dangling symlink, so -L is needed to catch that case too).
  if [ -e "$f" ] || [ -L "$f" ]; then
    [ -f "$f" ] && [ ! -L "$f" ] || return 0
  fi
  [ -r "$f" ] && { read -r first < "$f"; } 2>/dev/null
  printf -v line '%s dur_us=%s hook=%s event=%s ppid=%s tool=%s\n' \
    "$stamp" "$dur" "${MXHT_HOOK:--}" "${MXHT_EVENT:--}" "$PPID" "${MXHT_TOOL:--}"
  # One printf of ~90 bytes to an O_APPEND fd is atomic under PIPE_BUF, so
  # concurrent hooks cannot interleave partial lines.
  # The 2>/dev/null wraps the GROUP, not the printf: a failing redirection is
  # reported by the shell before an inner 2>/dev/null would be in effect, and a
  # "Permission denied" on stderr from a PreToolUse hook is surfaced to the model.
  if [ "${first%% *}" = "${stamp%% *}" ]; then
    { printf '%s' "$line" >>"$f"; } 2>/dev/null
  else
    { printf '%s' "$line" >"$f"; } 2>/dev/null
  fi
}
# `|| :` IS LOAD-BEARING — do not simplify to `trap _mxht_emit EXIT`. The hooks
# this block is inlined into run under `set -e`, and the EXIT trap runs with
# errexit still in force. Without `|| :`, a non-zero return from the probe — the
# final printf failing on an unwritable slot is the reachable case — replaces the
# hook's own exit status: a PreToolUse guard's exit 2 becomes exit 1, which the
# harness reads as "error", not "block", so the command it meant to block runs.
# Pinned verbatim by hook-timing-parity.test.sh.
trap '_mxht_emit || :' EXIT
# <<< SHARED hook-timing probe v1


# Fail-open: any error → exit 0 silently
trap 'exit 0' ERR
set -u

# Save the real stderr to fd 3 up front. Section 2 opens the advisory lock with
# `exec 200>"$LOCK_FILE" 2>/dev/null`, and bash applies that `2>/dev/null` to the
# whole shell — permanently redirecting fd 2 to /dev/null. The threshold reminder
# below therefore writes to fd 3 (the preserved real stderr) so the harness can
# surface it (KI-HOOKS-STDERR-SYSTEMIC). Fail-open if fd 3 can't be opened.
exec 3>&2 || true

INPUT=$(cat)
# `|| TOOL_NAME=""` is load-bearing: a bare assignment from a pipeline carries
# the pipeline's status, so a missing or broken jq (127) would reach the ERR trap
# above and take the hook out silently — no reminder, no error, for the rest of
# the session. Degrading to an empty tool name instead means the call is treated
# as "not a memory save", which over-reminds rather than never reminding.
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || TOOL_NAME=""

# PAYLOAD OPAQUE (#4863 security review). An empty tool name means the payload could
# not be read — broken/missing jq, or a shape this hook does not understand. #4863's
# capability observation is built entirely on reading tool names, so in that state it
# can NEVER observe capability: every session would look read-only, and a read-only
# session is the one branch that goes quiet. Left unhandled, a broken jq turns this
# hook permanently inert after a single advisory — precisely the silent-disable the
# `|| TOOL_NAME=""` above exists to prevent, reintroduced one layer up.
#
# So an unreadable payload is treated as CAPABLE: the demand fires. That is the
# over-report direction (#4755/#4756), consistent with every other degradation in
# this file — a session that cannot act may see a demand it must reason about,
# which is recoverable; a fleet-wide silent checkpoint gate is not.
PAYLOAD_OPAQUE=0
if [[ -z "$TOOL_NAME" ]]; then
  PAYLOAD_OPAQUE=1
fi

# Session attribution (#4802), same parse and same sanitization #4758 established
# for deploy-verification-reminder.sh. `|| SESSION_ID=""` is load-bearing for the
# same reason TOOL_NAME's is: a missing or broken jq must degrade to the legacy
# worktree-wide key, not return non-zero into the ERR trap and take the hook out.
# The value is used only as a filename component, never printed into the message,
# so no harness-supplied text reaches agent context through it.
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) || SESSION_ID=""
SESSION_ID="${SESSION_ID//[^a-zA-Z0-9_-]/}"
# BOUND THE LENGTH, not just the character set (#5136 review). Sanitizing for path
# traversal is not enough: these ids become filename components, and NAME_MAX is 255
# bytes on every filesystem this runs on. An over-long id pushes the counter name past
# that limit, every write to it fails ENAMETOOLONG into a `|| true`, the counter can
# never be read back or incremented, and the hook goes PERMANENTLY SILENT for that
# session — no counter, no threshold, no reminder, and no error either. Reproduced both
# ways before fixing: a 400-character session_id and a 400-character agent_id each
# produced zero counter files across repeated calls.
#
# That is the same silent-disable this file rejects everywhere else, reached through
# the filesystem instead of through a code path, and for session_id it PRE-DATES the
# subagent work. 64 is far above any real id — session ids are 36-character UUIDs and
# agent ids are ~17 hex characters — so this truncates nothing the harness actually
# sends, and truncation losing an unlikely collision to a shared counter is strictly
# better than the gate silently switching itself off.
SESSION_ID="${SESSION_ID:0:64}"

# Subagent attribution (#5136). Same parse, same sanitization, same load-bearing
# `|| AGENT_ID=""` as the two captures above — a broken jq must degrade, not return
# non-zero into the ERR trap. The value is used only as a filename component and as a
# 0/1 branch selector; it is never printed into the message, so no harness-supplied
# text reaches agent context through it.
#
# .agent_id, not .agent_type: the harness sets agent_id only inside a dispatched
# subagent, while agent_type is ALSO set on the main thread of an --agent session.
# Branching on agent_type would drop the demand for a lead that happens to have been
# started with --agent — a silent, invisible weakening of the gate for a real leader.
# See DISPATCHED SUBAGENTS in the header for the two sources this rests on, quoted.
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty' 2>/dev/null) || AGENT_ID=""
AGENT_ID="${AGENT_ID//[^a-zA-Z0-9_-]/}"
# Same NAME_MAX bound as SESSION_ID above, same reason — and here it compounds, since
# a subagent's counter name carries BOTH components.
AGENT_ID="${AGENT_ID:0:64}"
# `if`, not `[[ ... ]] && VAR=1` — FL-13, never leave a failing test in reach of the
# ERR trap.
IS_SUBAGENT=0
if [[ -n "$AGENT_ID" ]]; then
  IS_SUBAGENT=1
fi

# NO SESSION ID → NO REMINDER (#5013). Everything below this line — the counter, the
# capability marker, the advisory, the demand — is a statement about ONE session. With
# no session id there is no key that isolates one session, only the worktree-wide key
# shared by every agent in the worktree, and a count taken from a shared key is not a
# fact about the agent being handed it. Exit before any of that state is created or
# read, so no shared counter file can exist to be misattributed later. Silent and
# exit 0: this path is not an error, and a message here would be the false premise
# this guard exists to prevent. Rationale in full in the header.
#
# SCOPED TO THE READABLE-PAYLOAD CASE ON PURPOSE. The condition is "jq worked and the
# harness supplied no session id", not "no session id for any reason". A payload this
# hook cannot parse (PAYLOAD_OPAQUE) keeps its existing behaviour — worktree-wide key,
# demand fires — because #4863's security review specifically established that a
# broken or missing jq must not silently disable this gate, and reproduced the
# inertness it causes. That property is worth more than closing a false-premise path
# that only opens when jq itself is broken: a broken jq is a rare, machine-wide fault,
# while a harness that omits session_id is a routine condition on the hot path. This
# does leave one false-premise path alive under broken jq. It is documented, not
# overlooked; closing it needs #4863 reopened, not a quiet reversal here.
if [[ "$PAYLOAD_OPAQUE" -eq 0 && -z "$SESSION_ID" ]]; then
  exit 0
fi

WORKTREE_NAME="${CLAUDE_WORKTREE_NAME:-default}"
# Sanitize: alphanumeric, hyphens, underscores only — prevent path traversal
WORKTREE_NAME="${WORKTREE_NAME//[^a-zA-Z0-9_-]/}"
# Bound the length too, for the same NAME_MAX reason as SESSION_ID and AGENT_ID above
# (#5136 review). This is the THIRD filename component and the last one left uncapped:
# an over-long worktree name overruns NAME_MAX on its own, every write to the counter
# fails ENAMETOOLONG into a `|| true`, and the gate goes permanently silent with no
# counter and no error. Reproduced before fixing — a 400-character CLAUDE_WORKTREE_NAME
# produced zero counter files across repeated calls, exactly as the two id fields did.
#
# Lower exposure than the other two, since this is a locally-set environment variable
# rather than a harness-supplied payload field, and that is precisely why it is a cap
# and not a rejection: it is not an attack to defend against, it is a sibling of a
# defect just fixed one screen up, and leaving it would mean shipping a known instance
# of the failure this file rejects everywhere else. Truncation cannot empty a non-empty
# name, so the `:-default` fallback below still only catches the all-invalid-chars case
# and must stay AFTER this line.
WORKTREE_NAME="${WORKTREE_NAME:0:64}"
WORKTREE_NAME="${WORKTREE_NAME:-default}"
COUNTER_FILE="/tmp/.mx-checkpoint-counter-${WORKTREE_NAME}"
# Per-session counter file. Naming follows #4758's `${STATE_FILE}.notice-${SESSION_ID}`
# convention — derive from the existing base rather than inventing a second scheme —
# so the worktree base name still identifies the family and a worktree name can never
# collide with a worktree+session name. No session id → legacy worktree-wide file.
# `if`, not `[[ ... ]] && ...` — this file's own FL-13 policy: never leave a
# failing test within reach of the ERR trap, regardless of whether a given
# position happens to be exempt today.
if [[ -n "$SESSION_ID" ]]; then
  COUNTER_FILE="${COUNTER_FILE}.session-${SESSION_ID}"
fi
# Per-subagent counter (#5136). A dispatched subagent runs under its dispatcher's
# session_id, so the session suffix alone puts the lead and every concurrent subagent
# on ONE counter — see COUNTING SCOPE in the header. Appending the agent id splits
# them. Same derive-from-the-base convention as the session suffix, and appended
# AFTER it so the existing sweep pattern (`*.session-*`) still matches the whole
# family; the .cap and .advised companions below inherit the split for free.
if [[ "$IS_SUBAGENT" -eq 1 ]]; then
  COUNTER_FILE="${COUNTER_FILE}.agent-${AGENT_ID}"
fi
THRESHOLD=50
# Companion state, named off the counter base for the same reason the per-session
# counter is (#4758's convention): one family of files, one sweep pattern.
#   .cap     — this session has been OBSERVED using a memory-capable tool (#4863)
#   .advised — the once-per-session advisory has already been emitted (#4863)
CAP_FILE="${COUNTER_FILE}.cap"
ADVISED_FILE="${COUNTER_FILE}.advised"

# Reset counter if it's stale (from a previous session — >30 min old)
if [[ -f "$COUNTER_FILE" ]]; then
  MTIME=$(stat -c %Y "$COUNTER_FILE" 2>/dev/null || stat -f %m "$COUNTER_FILE" 2>/dev/null || echo "0")
  NOW=$(date +%s)
  AGE=$((NOW - MTIME))
  if [[ "$AGE" -gt 1800 ]]; then
    echo "0" > "$COUNTER_FILE" 2>/dev/null || true
    # A stale counter means the key is being reused by different work; the
    # capability and advisory observations belonged to that older occupant, so
    # they go with it. Dropping them re-arms the advisory rather than the demand,
    # which is the safe direction: it re-observes before it re-demands.
    rm -f "$CAP_FILE" "$ADVISED_FILE" 2>/dev/null || true
  fi
fi

# ── 0. Observe memory-write capability (#4863) ───────────────────────────────
# Positive evidence only, from tool calls this hook is already given. Bash → the
# session can run `mx memories create`. A MemNexus MCP tool → the server is
# connected, so create_memory is callable. Cheap on the hot path: a builtin test,
# and at most one file creation per session.
#
# The MCP patterns mirror section 1's save-detection substrings rather than listing
# server prefixes: section 1 already accepts *create_memory* / *manage_memory* under
# any prefix, and a tool this hook counts as a memory SAVE must also count as
# evidence the session can save. Two different notions of "a memory tool" in one
# file is how a capable session ends up classified read-only.
case "$TOOL_NAME" in
  Bash|mcp__memnexus__*|mcp__MX__*|*create_memory*|*manage_memory*|*memnexus*)
    if [[ ! -f "$CAP_FILE" ]]; then
      : > "$CAP_FILE" 2>/dev/null || true
    fi
    ;;
esac

# ── 1. Detect memory save operations ────────────────────────────────────────
# Reset counter if this tool call IS a memory save:
# - MCP tools: mcp__memnexus__create_memory, mcp__memnexus__manage_memory
#   (also handle mcp__MX__ prefix used in some environments)
# - Bash tool: command contains "mx memories create" or "mx memories update"

IS_MEMORY_SAVE=0

if [[ "$TOOL_NAME" == *"create_memory"* || "$TOOL_NAME" == *"manage_memory"* ]]; then
  IS_MEMORY_SAVE=1
fi

if [[ "$TOOL_NAME" == "Bash" ]]; then
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || COMMAND=""
  if [[ "$COMMAND" == *"mx memories create"* || "$COMMAND" == *"mx memories update"* ]]; then
    IS_MEMORY_SAVE=1
  fi
fi

if [[ "$IS_MEMORY_SAVE" -eq 1 ]]; then
  # Reset counter — memory was just saved
  echo "0" > "$COUNTER_FILE" 2>/dev/null || true
  exit 0
fi

# ── 2. Increment counter (use flock for atomic operation if available) ────────
LOCK_FILE="${COUNTER_FILE}.lock"
CURRENT=0

if command -v flock >/dev/null 2>&1; then
  exec 200>"$LOCK_FILE" 2>/dev/null
  flock -x 200 2>/dev/null || true
fi

if [[ -f "$COUNTER_FILE" ]]; then
  CURRENT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
  [[ "$CURRENT" =~ ^[0-9]+$ ]] || CURRENT=0
fi

CURRENT=$((CURRENT + 1))
echo "$CURRENT" > "$COUNTER_FILE" 2>/dev/null || true

# ── 3. Emit reminder if threshold reached ────────────────────────────────────
if [[ "$CURRENT" -ge "$THRESHOLD" ]]; then
  # Reset counter — after save + retry, next 50 calls proceed unblocked
  echo "0" > "$COUNTER_FILE" 2>/dev/null || true

  # Capability decision (#4863). `if`, not `[[ ... ]] && VAR=1` — FL-13, never
  # leave a failing test in reach of the ERR trap.
  CAPABLE=0
  if [[ -f "$CAP_FILE" ]]; then
    CAPABLE=1
  fi
  # An unreadable payload cannot have been observed, so it must not be scored as
  # "observed read-only" — see PAYLOAD_OPAQUE above. Degrade to the demand.
  if [[ "$PAYLOAD_OPAQUE" -eq 1 ]]; then
    CAPABLE=1
  fi

  # Branch selection (#5136). Two independent reasons to advise rather than demand,
  # and either one is sufficient:
  #   - nothing observed  → the session may have no tool to satisfy a demand (#4863)
  #   - dispatched subagent → the session has a tool but is not the responsible party
  # Under an opaque payload neither can be read (AGENT_ID is empty for the same reason
  # TOOL_NAME is), so IS_SUBAGENT is 0 and the demand fires — the over-report direction
  # every other degradation in this file picks.
  ADVISORY=0
  if [[ "$CAPABLE" -eq 0 ]]; then
    ADVISORY=1
  fi
  if [[ "$IS_SUBAGENT" -eq 1 ]]; then
    ADVISORY=1
  fi

  # A session with no observed memory-write tool is advised at most once. Past
  # that, staying quiet is the correct behaviour: it has already been told, it
  # cannot act, and every further interruption is cost with no possible outcome.
  # This is not the hook being disabled — the demand returns the moment the
  # session uses Bash or a MemNexus MCP tool.
  #
  # The `-n "$SESSION_ID"` was the load-bearing part (#4863 security review): the
  # quiet period is a claim about ONE session ("this session has already been
  # advised"), so it may only be honoured when the state it reads is actually keyed
  # to a session. As of #5013 it is UNREACHABLE-FALSE — a sessionless call now exits
  # at the top of the file and never gets here, so SESSION_ID is always non-empty at
  # this point. Retained as a defence-in-depth assertion, not as live logic: it costs
  # one builtin test on a path that runs once per 50 calls, and it keeps this branch
  # correct on its own terms if the top-of-file guard is ever moved or relaxed. Do
  # not read it as evidence that a sessionless call can reach this branch.
  # ADVISORY, not CAPABLE, gates the quiet period as of #5136 — a subagent that is
  # advised once must not be re-advised every 50 calls either. The suppression state
  # is keyed to the counter, so for a subagent it is per-agent, not per-session.
  if [[ "$ADVISORY" -eq 1 && -n "$SESSION_ID" && -f "$ADVISED_FILE" ]]; then
    exit 0
  fi

  # Prune per-session counter files left behind by ended sessions (#4802). One
  # session leaves up to four small files in /tmp (counter, .lock, and #4863's
  # .cap/.advised — all matched by the same trailing wildcard); without a sweep
  # they accumulate for
  # the life of the host. Deliberately INSIDE the threshold branch — the same
  # reason the slug resolution below is: this hook runs on every tool call, and
  # a `find` on the hot path would tax all 50 for work that matters once. Bounded
  # by -maxdepth 1 and a fixed name pattern; best-effort, errors ignored (other
  # users' files are simply not ours to delete).
  # Bounded (#4846 review). This is the one call in this hook that touches a
  # directory it does not own the size of: /tmp on a loaded machine can hold
  # enough entries for the sweep to run for seconds, and this hook's harness
  # timeout is the thing standing between a slow sweep and a lost reminder.
  # Best-effort either way — on timeout the stale files are simply swept on a
  # later run, which is what `|| true` already meant for any other failure.
  timeout "${MCR_SWEEP_TIMEOUT_SECS:-5}" \
    find /tmp -maxdepth 1 -name '.mx-checkpoint-counter-*.session-*' -mmin +1440 -delete 2>/dev/null || true

  # ── 3a. Resolve codeContext for the suggested command (#4652) ──────────────
  # Deliberately INSIDE the threshold branch. This hook runs on every single
  # tool call; the resolved slug is used once per 50. Resolving it on the hot
  # path taxed every call for a result thrown away 49 times out of 50, and a
  # pathological marker file made that tax unbounded.
  #
  # Without --code-context every hook-forced checkpoint lands with codeContext
  # null, so it does not scope to a team and other sessions' searches surface it
  # by accident. The command we print is the command agents run verbatim, so the
  # flag has to be IN the printed command, not in prose next to it.
  #
  # Slug resolution copies coordination-brief.sh's MF-2 rule: the
  # .mx-coordination-team marker is the ONLY source, because it is an explicit
  # assignment written by an onboarding step. We do NOT derive from
  # CLAUDE_WORKTREE_NAME or CLAUDE_TEAM_NAME — those are the unreliable
  # heuristics that design rejects, and a confidently WRONG team is worse than a
  # placeholder the agent has to fill in consciously.
  MARKER_FILE="${CLAUDE_WORKTREE_PATH:-}/.mx-coordination-team"
  TEAM_SLUG=""
  if [[ -n "${CLAUDE_WORKTREE_PATH:-}" && -f "$MARKER_FILE" ]]; then
    # Read a BOUNDED prefix. A slug is at most 50 bytes; 512 is generous and
    # bounds the work regardless of what the file actually contains. Without the
    # cap, a large no-newline file is read in full before the first line ends.
    #
    # Every capture handles its own failure (`|| VAR=""`) rather than leaving a
    # non-zero status in reach of this script's ERR trap: if `head` is missing
    # the resolution degrades to the placeholder instead of taking the hook down.
    TEAM_SLUG=$(head -c 512 "$MARKER_FILE" 2>/dev/null | head -n1) || TEAM_SLUG=""

    # Reject a marker containing a NUL byte. Bash strips NUL from command
    # substitution output, so "plat\0form" would otherwise arrive as the
    # plausible-looking slug "platform" and pass validation — the same
    # silently-mangled-into-a-different-team failure the whitespace handling
    # below exists to prevent. `read -d ''` returns 0 only when it actually
    # found a NUL delimiter, which makes it a NUL detector; the same 512-byte
    # cap applies. Pure bash, no new dependency.
    if head -c 512 "$MARKER_FILE" 2>/dev/null | IFS= read -r -d '' _nul_probe; then
      TEAM_SLUG=""
    fi

    # TRIM leading/trailing whitespace in pure bash — no `sed`, no `tr`.
    # Not `tr -d '[:space:]'`: that DELETES interior whitespace too, turning a
    # marker reading "has space" into the valid-looking slug "hasspace".
    # Interior whitespace must survive to the regex so it fails and yields the
    # placeholder. Pure-bash form also keeps a missing external binary from
    # being able to influence this at all.
    TEAM_SLUG="${TEAM_SLUG#"${TEAM_SLUG%%[![:space:]]*}"}"
    TEAM_SLUG="${TEAM_SLUG%"${TEAM_SLUG##*[![:space:]]}"}"
  fi

  # Validate before interpolating: the marker is a file on disk and its contents
  # land inside a shell command we are telling an agent to run. Same whole-string
  # regex coordination-brief.sh validates with (bash [[ =~ ]] anchors ^/$ to the
  # string ends, so an embedded newline fails), plus its two extra exclusions —
  # no '--' and not the reserved 'all'. Keeping the validators identical matters:
  # a slug coordination-brief rejects is not a usable team anywhere in the fleet,
  # so scoping a memory to it would be as wrong as guessing. Invalid → placeholder.
  is_valid_team_slug() {
    local s="$1"
    case "$s" in
      *--*) return 1 ;;
      all) return 1 ;;
    esac
    [[ "$s" =~ ^[a-z][a-z0-9-]{0,48}[a-z0-9]$ ]]
  }
  if ! is_valid_team_slug "$TEAM_SLUG"; then
    TEAM_SLUG=""
  fi

  # Project: MX_PRODUCT_SLUG → mx-agent.config.json projectSlug → memnexus.
  # Same precedence as coordination-brief.sh. jq is a pre-existing dependency of
  # this hook (section 1 parses the tool payload with it); the trim is pure bash.
  PROJECT_SLUG="${MX_PRODUCT_SLUG:-}"
  if [[ -z "$PROJECT_SLUG" && -n "${CLAUDE_WORKTREE_PATH:-}" && -f "${CLAUDE_WORKTREE_PATH}/mx-agent.config.json" ]]; then
    PROJECT_SLUG=$(jq -r '.projectSlug // .project.slug // empty' "${CLAUDE_WORKTREE_PATH}/mx-agent.config.json" 2>/dev/null) || PROJECT_SLUG=""
    PROJECT_SLUG="${PROJECT_SLUG%%$'\n'*}"
    PROJECT_SLUG="${PROJECT_SLUG#"${PROJECT_SLUG%%[![:space:]]*}"}"
    PROJECT_SLUG="${PROJECT_SLUG%"${PROJECT_SLUG##*[![:space:]]}"}"
  fi
  if ! is_valid_team_slug "$PROJECT_SLUG"; then
    PROJECT_SLUG="memnexus"
  fi

  if [[ -n "$TEAM_SLUG" ]]; then
    CODE_CONTEXT="{\"product\":\"${PROJECT_SLUG}\",\"team\":\"${TEAM_SLUG}\"}"
    CODE_CONTEXT_NOTE=""
  else
    CODE_CONTEXT="{\"product\":\"${PROJECT_SLUG}\",\"team\":\"<YOUR-TEAM-SLUG>\"}"
    CODE_CONTEXT_NOTE="   Replace <YOUR-TEAM-SLUG> with the 'team' value from your roleguide's codeContext — the team marker could not be read, and a guessed team scopes the memory to the wrong search."
  fi

  SAVE_COMMAND="  mx memories create --conversation-id \"<conv_id>\" --content \"[what you've done]. Next: [next step].\" --topics \"<component>,in-progress\" --code-context '${CODE_CONTEXT}'"
  # #5006. `<conv_id>` on its own reads as "any conversation id", and an agent whose
  # brief named no conversation fills it with "NEW" — which is the documented default
  # for starting a topic. Each such save opens a fresh conversation, so one iteration's
  # record ends up split across several, and nothing reports that it happened. The
  # placeholder cannot carry this itself without ceasing to be copy-pasteable, so the
  # guidance is a line beside it, matching how CODE_CONTEXT_NOTE is handled above.
  CONV_ID_NOTE="   <conv_id>: use the conversation id from your task brief. If you have none, ask the session that dispatched you rather than passing \"NEW\" — a new conversation per checkpoint fragments one iteration's record across several."

  # ── 3b. Provenance block (#4734/#4863) ────────────────────────────────────
  # An interruption that demands work is indistinguishable, by shape, from the
  # injected content agents are trained to distrust — and content shaped like
  # THIS hook is a documented injection pattern (#4697). The message therefore
  # names the file that produced it and the condition that produced it, so the
  # receiving agent can check the claim in the repo instead of guessing. This is
  # not proof of origin (nothing available here is); it is a checkable claim,
  # which is strictly more than the message carried before. The message says so
  # itself: an agent that reads "verify this against the repo" and finds a match
  # must not conclude the message therefore came from the repo — anything can copy
  # a file path. AGENTS.md's rule that channel shape is not an authenticity signal
  # applies to this block too, and a provenance footer that implied otherwise
  # would be a better injection vehicle than no footer at all.
  print_provenance() {
    echo "   Provenance: emitted by the PreToolUse hook memory-checkpoint-reminder.sh"
    echo "   (source: mx-agent-system/agent-config/hooks/memory-checkpoint-reminder.sh;"
    echo "   installed copy: .claude/hooks/memory-checkpoint-reminder.sh). Trigger:"
    echo "   ${THRESHOLD} tool calls in this session with no memory save. Verify it against"
    echo "   those files before acting — a message of this shape that does not match"
    echo "   them did not come from this hook. A match is NOT proof of origin: it"
    echo "   makes this message's content checkable, not its source authenticated."
  }

  # Route to the preserved real stderr (fd 3) — exit-2 PreToolUse messages
  # surface via stderr, not stdout (KI-HOOKS-STDERR-SYSTEMIC). fd 2 may have been
  # redirected to /dev/null by the flock exec above, so fd 3 is used here.
  if [[ "$ADVISORY" -eq 1 ]]; then
    # ADVISORY — nothing here is required of the receiving session. Two shapes,
    # because the two reasons for advising are different facts and a message that
    # states the wrong one is a false premise of the kind this file exists to avoid:
    #   - subagent (#5136): the session probably CAN save and should not be the one to
    #   - read-only (#4863): the hook has no evidence the session can save at all
    : > "$ADVISED_FILE" 2>/dev/null || true
    if [[ "$IS_SUBAGENT" -eq 1 ]]; then
      {
        echo ""
        echo "ℹ️  Memory checkpoint advisory: ${CURRENT} tool calls in this subagent since the last memory save."
        echo "   NO ACTION IS REQUIRED, AND NO CHECKPOINT IS BEING ASKED OF YOU. The hook payload"
        echo "   carries an agent_id, which the harness sets only inside a dispatched subagent, so"
        echo "   this session is one. Checkpointing this work belongs to the session that dispatched"
        echo "   you, which holds the conversation the work is recorded in."
        echo "   - Do NOT save with --conversation-id NEW to satisfy this notice. A new conversation"
        echo "     per checkpoint splits one iteration's record across several, and you have no"
        echo "     synchronous channel to ask your dispatcher for the right id mid-task."
        echo "   - Put your progress in your final report instead. That is what your dispatcher"
        echo "     reads, and it is where this information is supposed to go."
        echo "   - If your brief DID give you a conversation id and told you to checkpoint, follow"
        echo "     the brief — it outranks this notice, which knows nothing about your task."
        echo "   Retry your original tool call to proceed. This advisory is emitted once per subagent."
        print_provenance
        echo ""
      } >&3
    else
      {
        echo ""
        echo "ℹ️  Memory checkpoint advisory: ${CURRENT} tool calls in this session since the last memory save."
        echo "   NO ACTION IS REQUIRED IF YOU HAVE NO MEMORY-WRITE TOOL. This hook cannot see"
        echo "   your session's toolset, and it has not observed this session use Bash or a"
        echo "   MemNexus MCP tool, so it does not assume you can save a checkpoint."
        echo "   - If you CAN save one, this is the command:"
        echo "$SAVE_COMMAND"
        echo "     (or the create_memory MCP tool, with the same codeContext)."
        echo "$CONV_ID_NOTE"
        if [[ -n "$CODE_CONTEXT_NOTE" ]]; then
          echo "$CODE_CONTEXT_NOTE"
        fi
        echo "   - If you CANNOT, ignore this and continue. Report your findings to the session"
        echo "     that dispatched you; checkpointing is that session's responsibility, not yours."
        echo "   Retry your original tool call to proceed. This advisory is emitted once per session."
        print_provenance
        echo ""
      } >&3
    fi
    exit 2  # Surface this message to the agent — exit 2 is required for PreToolUse visibility
  fi

  {
    echo ""
    echo "⚠️  Memory checkpoint required: ${CURRENT} tool calls since last memory save."
    # The cross-session caveat #4734 added here is GONE as of #4802, because it is
    # no longer true: the counter is keyed by session, so this count is the reading
    # session's own tool calls. #4734 could only correct the message because the
    # counter's scope was out of scope for that change; #4802 fixed the scope, which
    # is what the caveat was compensating for. A caveat that no longer describes the
    # code is the same defect class in the other direction.
    echo "1. Save a checkpoint now (required before continuing):"
    echo "$SAVE_COMMAND"
    echo "$CONV_ID_NOTE"
    # `if`, not `[[ ... ]] && echo`. Policy, not a claim about this line: never
    # leave a failing test within reach of an ERR trap. Whether a given position
    # is exempt depends on where the list sits and how it is nested — too fragile
    # to reason about per-site or to preserve across edits (FL-13).
    if [[ -n "$CODE_CONTEXT_NOTE" ]]; then
      echo "$CODE_CONTEXT_NOTE"
    fi
    echo "2. After saving, retry your original tool call."
    print_provenance
    echo ""
  } >&3
  exit 2  # Surface this message to the agent — exit 2 is required for PreToolUse visibility
fi

exit 0
