#!/usr/bin/env bash # Scribe — PreToolUse moment arm: rules MOUNTED on a moment arrive when the # moment happens (milestone 458 step 4). # # The other PreToolUse arms search: they turn the act into a query and hope a # rule's words resemble it. A mount needs no resemblance. The operator said # "this rule applies when work is delivered", the install maps `git push` onto # work.deliver, and so `git push` brings the rule — whatever the command's # words happen to be. The server resolves the call to its moments and looks up # what is mounted there; this hook only carries the event and the answer. # # REGISTERED ON EVERY TOOL, KEPT OFF THE WIRE FOR MOST OF THEM. Which calls can # reach a mounted rule depends on this install's mappings and mounts, so the # matcher cannot say. Instead the hook keeps the server's answer to "which # tools can?" (`/moment-tools`) on disk for a few minutes, and a call to any # other tool costs one file read. An install that has mounted nothing gets an # empty list and never sends a moment request at all. # # SCRIBE'S OWN TOOLS ARE SKIPPED. Closing a task or recording a lesson reaches # its moment through the tool's own response (`moment_rules`), which works for # a client without this plugin too; delivering it here as well would say it # twice. # # SILENT ON OUTAGE, like scribe_tool_rules.sh and for its reason: this fires # before many calls, and an outage line before each is noise. A failed list # fetch is cached as an empty list, so a down instance costs one timeout per # window rather than one per call. # # Env: # SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path. command -v curl >/dev/null 2>&1 || exit 0 # shellcheck source=plugin/hooks/scribe_defs.sh . "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh" event=$(cat 2>/dev/null || true) event_flat=$(printf '%s' "$event" | scribe_json_flat) tool_name=$(scribe_json_pick "$event_flat" '.tool_name') session_id=$(scribe_json_pick "$event_flat" '.session_id') event_cwd=$(scribe_json_pick "$event_flat" '.cwd') [ -n "$tool_name" ] && [ -n "$session_id" ] || exit 0 case "$tool_name" in mcp__*scribe*__*) exit 0 ;; esac scribe_config || exit 0 # The tool's KEY, as the server writes mappings: no MCP server prefix, lowercase. tool_key=${tool_name##*__} tool_key=$(printf '%s' "$tool_key" | tr '[:upper:]' '[:lower:]') state_dir="${TMPDIR:-/tmp}/scribe-priorart" mkdir -p "$state_dir" 2>/dev/null || true safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') # ── Which tools can reach a mounted rule: cached, first line a timestamp ── # # Five minutes. A mount or a mapping made in this session reaches the hook # within that window; the MCP door delivers Scribe's own moments at once. # Its own directory, outside SCRIBE_LEDGER_DIRS, so a compaction does not # sweep it: it describes the install, not what this context holds. cache_dir="${TMPDIR:-/tmp}/scribe-moment" mkdir -p "$cache_dir" 2>/dev/null || true tools_file="$cache_dir/${safe_sid}.tools" now=$(date +%s 2>/dev/null) || now=0 stamp="" [ -f "$tools_file" ] && stamp=$(head -n 1 "$tools_file" 2>/dev/null) case "$stamp" in ''|*[!0-9]*) stamp=0 ;; esac if [ "$now" -eq 0 ] || [ $((now - stamp)) -gt 300 ]; then listed=$(curl -fsS --max-time 3 \ -H "Authorization: Bearer ${token}" \ "${url%/}/api/plugin/moment-tools" 2>/dev/null) || listed="" { printf '%s\n' "$now" scribe_json_list "$(printf '%s' "$listed" | scribe_json_flat)" '.tools' } > "$tools_file" 2>/dev/null || true fi tail -n +2 "$tools_file" 2>/dev/null | grep -Fqx -- "$tool_key" || exit 0 # ── The moment request ──────────────────────────────────────────────────── repo_q="" lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}} scope=$(scribe_scope_query "$lookup_dir") [ -n "$scope" ] && repo_q="&${scope}" # The shared session ledger, as scribe_tool_rules.sh reads and writes it: a # rule this session was already shown is cited, not quoted again. rulefile="$state_dir/${safe_sid}.rules.ids" ledger_q="" rule_seen=$(scribe_rules_live "$rulefile") [ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}" ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")" # The event whole, since which field a mapping matches is the mapping's # business. A write of a very large file is reduced to its name: the moments a # write reaches never depend on its content. if [ "${#event}" -gt 65536 ]; then esc=$(printf '%s' "$tool_name" | scribe_json_escape) || exit 0 event="{\"tool_name\":\"${esc}\",\"tool_input\":{}}" fi query="${repo_q}${ledger_q}" query=${query#&} body=$(printf '%s' "$event" | curl -fsS --max-time 3 \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ --data-binary @- \ "${url%/}/api/plugin/moment${query:+?$query}" 2>/dev/null) || exit 0 body_flat=$(printf '%s' "$body" | scribe_json_flat) scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile" context=$(scribe_json_pick "$body_flat" '.context') [ -n "$context" ] || exit 0 scribe_json_out PreToolUse "$context" exit 0