CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 37s
The signal counted every get_rule open against every moment from the previous three minutes, so a rule a search arm had just shown was proposed for whatever moment happened to fire. All nine pending signal proposals were wrong. - The hook skips an open of a rule a hook named this session, and sends the last three acts from 90 seconds instead of 30 from 180. - A rule already mounted anywhere gathers no signal evidence. - A signal pair keeps the actions that reached its moment and shows them as its why. - The proposal line says it is co-occurrence, not a recommendation, and asks the reader to check the rule against the moment before offering it; moments.md says the same. Plugin 2026.10.26.2029. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
5.5 KiB
Bash
114 lines
5.5 KiB
Bash
#!/usr/bin/env bash
|
|
# Grimoire — record that this session OPENED a rule, not merely saw it named.
|
|
#
|
|
# WHAT THIS ANSWERS
|
|
#
|
|
# The rule arms keep a ledger of every id they have NAMED. The line those arms
|
|
# emit is a TEASER — title, trigger, `get_rule(N)` — so a session can be named
|
|
# a rule twenty times and never read one word of it, and after a compaction
|
|
# the teaser is summarised away leaving nothing at all. Named is not read, so
|
|
# the server needs to be told which rules were opened.
|
|
#
|
|
# This is the observable half. PostToolUse fires for MCP tools (the event's own
|
|
# output schema carries `updatedMCPToolOutput`, which would be meaningless
|
|
# otherwise), so the `get_rule` CALL can be watched directly.
|
|
#
|
|
# WHY THIS IS NOT SELF-REPORT
|
|
#
|
|
# Asking the session whether it holds a rule is useless: a model asked "do you
|
|
# still hold rule N?" will say yes, and the answer is unverifiable. That
|
|
# objection is about ASKING. This asks nobody:
|
|
# a tool call happened or it did not, and the harness reports it either way.
|
|
# Recording what a session DID is a different kind of evidence from believing
|
|
# what it says about itself.
|
|
#
|
|
# WHAT IT DELIBERATELY DOES NOT DO
|
|
#
|
|
# It does not prove the rule is still in context — nothing can, and a
|
|
# compaction can drop it moments later. That is why `.opened.ids` ages exactly
|
|
# like `.rules.ids` and is cleared on the same events: both ledgers
|
|
# describe a context that no longer exists once the context is destroyed. The
|
|
# claim it supports is only ever "you opened this, pull it again if you no
|
|
# longer hold it", which stays true in every case and carries its own remedy.
|
|
#
|
|
# It also reports the open, with the calls that came just before it, so the server can learn which moments a rule belongs
|
|
# on (below). Silent on outage, like every arm that fires per call.
|
|
#
|
|
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
|
|
# turn a successful tool call into a hook error. Worst case the id is missed
|
|
# and the reader is offered a rule it already read — the cost of a wrong guess
|
|
# here is one extra line, in the direction that shows more rather than less.
|
|
set -uo pipefail
|
|
|
|
# shellcheck source=plugin/hooks/grimoire_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
|
|
|
|
event=$(cat 2>/dev/null || true)
|
|
[ -n "$event" ] || exit 0
|
|
|
|
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
|
|
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
|
|
[ -n "$session_id" ] || exit 0
|
|
|
|
# The matcher in hooks.json already narrows to the get_rule tools, but the
|
|
# server segment of an MCP tool name varies with how the plugin was installed,
|
|
# so the id is read from whichever field is actually present rather than from
|
|
# an assumed tool name. An event that carries none simply records nothing.
|
|
rule_id=$(grimoire_json_pick "$event_flat" '.tool_input.rule_id')
|
|
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
|
|
[ -n "$rule_id" ] || exit 0
|
|
|
|
# The same directory the naming ledger uses. One session keeps its state in one
|
|
# place, and the prior-art name is kept for the reason grimoire_tool_rules.sh
|
|
# gives: renaming it would orphan every live session's state for a cosmetic
|
|
# gain.
|
|
state_dir="${TMPDIR:-/tmp}/grimoire-priorart"
|
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
|
|
|
# Stamped and append-only, exactly like the naming ledger — so the same reader
|
|
# (`grimoire_rules_live`) ages both, and the last entry for an id wins.
|
|
printf '%s\n' "$rule_id" | grimoire_rules_append "$state_dir/${safe_sid}.opened.ids"
|
|
|
|
# ── What came just before the open ───────────────────────────────────────
|
|
#
|
|
# grimoire_moment.sh writes every call to `grimoire-moment/<sid>.acts`. The
|
|
# last three, from the last ninety seconds, go to the server with this rule's
|
|
# id: a rule the agent went and opened right after a moment fired is evidence
|
|
# it belongs on that moment, and once that repeats across sessions the server
|
|
# answers with one line putting the question to the reader. Nothing is
|
|
# mounted by this.
|
|
#
|
|
# An open of a rule a hook has just NAMED is not reported. A ranked arm or a
|
|
# moment line put it in front of the reader, and that explains the open; the
|
|
# moment beside it did not, so counting it would propose mounts on whatever
|
|
# moment happened to fire while search was surfacing the rule. The window is
|
|
# short for the same reason: a few minutes of agent work reaches many moments.
|
|
command -v curl >/dev/null 2>&1 || exit 0
|
|
grimoire_config || exit 0
|
|
case ",$(grimoire_rules_live "$state_dir/${safe_sid}.rules.ids")," in
|
|
*",${rule_id},"*) exit 0 ;;
|
|
esac
|
|
acts_file="${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}.acts"
|
|
[ -f "$acts_file" ] || exit 0
|
|
now=$(date +%s 2>/dev/null) || exit 0
|
|
acts=$(awk -F '\t' -v since=$((now - 90)) \
|
|
'$1 + 0 >= since { sub(/^[^\t]*\t/, ""); print }' "$acts_file" 2>/dev/null \
|
|
| tail -n 3 | paste -sd ',' - 2>/dev/null)
|
|
[ -n "$acts" ] || exit 0
|
|
|
|
sid_esc=$(printf '%s' "$session_id" | grimoire_json_escape) || exit 0
|
|
event_cwd=$(grimoire_json_pick "$event_flat" '.cwd')
|
|
scope=$(grimoire_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
|
|
body=$(printf '{"rule_id":%s,"session_id":"%s","acts":[%s]}' "$rule_id" "$sid_esc" "$acts" \
|
|
| curl -fsS --max-time 3 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
-H "Content-Type: application/json" \
|
|
--data-binary @- \
|
|
"${url%/}/api/plugin/rule-opened${scope:+?$scope}" 2>/dev/null) || exit 0
|
|
|
|
context=$(grimoire_json_pick "$(printf '%s' "$body" | grimoire_json_flat)" '.context')
|
|
[ -n "$context" ] || exit 0
|
|
grimoire_json_out PostToolUse "$context"
|
|
exit 0
|