From ddcac965421ea6323af2931ef947e4230048e76d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 4 Nov 2026 21:51:34 -0500 Subject: [PATCH] fix(6855): the fence keeps its files where no compaction sweeps them, and builds id lists under mawk The project a session entered now lives in grimoire-fence, which the compaction clear never visits, and session end removes it. Arrays of ids in a tool's input were sent as [,"3","4"] under mawk, which creates an array element as soon as it is assigned to. Co-Authored-By: Claude Opus 5.5 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/grimoire_project_fence.sh | 10 ++++++++-- plugin/hooks/grimoire_session_end.sh | 10 ++++++---- tests/test_project_fence.py | 22 ++++++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index c7b0ac7d..388982f5 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "grimoire", "description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).", - "version": "2026.11.05.0245", + "version": "2026.11.05.0251", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/grimoire_project_fence.sh b/plugin/hooks/grimoire_project_fence.sh index 3b266bba..81a41965 100644 --- a/plugin/hooks/grimoire_project_fence.sh +++ b/plugin/hooks/grimoire_project_fence.sh @@ -51,7 +51,9 @@ session_id=$(grimoire_json_pick "$event_flat" '.session_id') tool_name=$(grimoire_json_pick "$event_flat" '.tool_name') hook_event=$(grimoire_json_pick "$event_flat" '.hook_event_name') cwd=$(grimoire_json_pick "$event_flat" '.cwd') -state_dir="${TMPDIR:-/tmp}/grimoire-priorart" +# Its own directory, which no compaction sweeps: the project a session +# entered stays its project for the whole session. +state_dir="${TMPDIR:-/tmp}/grimoire-fence" safe_sid=$(printf '%s' "${session_id:-none}" | tr -c 'A-Za-z0-9._-' '_') entered_file="$state_dir/${safe_sid}.entered" @@ -135,7 +137,11 @@ fence_tool_input() { } $2 ~ /^\.tool_input\.[A-Za-z_]+\[[0-9]+\]$/ && $3 != "null" && length($3) <= 200 { k = substr($2, 13); sub(/\[.*$/, "", k); add(k) - arr[k] = (k in arr) ? arr[k] ",\"" $3 "\"" : "\"" $3 "\"" + # Tested before assigning: mawk creates arr[k] as soon as it is the + # target, so the test inside the assignment would always be true. + item = "\"" $3 "\"" + if (k in arr) item = arr[k] "," item + arr[k] = item } END { printf "{" diff --git a/plugin/hooks/grimoire_session_end.sh b/plugin/hooks/grimoire_session_end.sh index 68ce9037..5232f29a 100755 --- a/plugin/hooks/grimoire_session_end.sh +++ b/plugin/hooks/grimoire_session_end.sh @@ -31,11 +31,13 @@ event_flat=$(printf '%s' "$event" | grimoire_json_flat) session_id=$(grimoire_json_pick "$event_flat" '.session_id') [ -n "$session_id" ] || exit 0 -# The moment arm's per-session files (its tool cache and the acts ledger) -# describe a session that is over, on any exit — a clear -# included, since the next conversation is a new session id. +# The moment arm's per-session files (its tool cache and the acts ledger) and +# the fence's (the project the session entered, its outage marker) describe a +# session that is over, on any exit — a clear included, since the next +# conversation is a new session id. safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') -rm -f "${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}".* 2>/dev/null || true +rm -f "${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}".* \ + "${TMPDIR:-/tmp}/grimoire-fence/${safe_sid}".* 2>/dev/null || true reason=$(grimoire_json_pick "$event_flat" '.reason') [ "$reason" = "clear" ] && exit 0 diff --git a/tests/test_project_fence.py b/tests/test_project_fence.py index 120a7e78..b83fd5a4 100644 --- a/tests/test_project_fence.py +++ b/tests/test_project_fence.py @@ -190,3 +190,25 @@ def test_an_unanswered_check_lets_the_act_through_and_says_so_once(tmp_path): ctx = json.loads(first)["hookSpecificOutput"]["additionalContext"] assert ctx.startswith("> Grimoire did not answer the project fence check") assert _run(tmp_path, 1, _edit(other / "b.py", home), home) == "" + + +def test_the_entered_project_outlives_a_compaction_and_ends_with_the_session(tmp_path): + # A compaction loses the context, not the session: the project it entered + # stays its project. Session end clears it. + loose = tmp_path / "loose" + loose.mkdir() + _run(tmp_path, 1, {"hook_event_name": "PostToolUse", "cwd": str(loose), + "tool_name": "mcp__plugin_grimoire_grimoire__enter_project", + "tool_input": {"project_id": 7}}, loose) + entered = tmp_path / "tmp" / "grimoire-fence" / "s-fence.entered" + assert entered.read_text() == "7" + + env = {"PATH": os.environ["PATH"], "TMPDIR": str(tmp_path / "tmp"), "HOME": str(tmp_path)} + hooks = HOOK.parent + for hook, event in (("grimoire_session_context.sh", {"source": "compact"}), + ("grimoire_session_end.sh", {"reason": "other"})): + subprocess.run(["bash", str(hooks / hook)], input=json.dumps({"session_id": "s-fence", **event}), + capture_output=True, text=True, env=env, timeout=60, check=True) + if hook == "grimoire_session_context.sh": + assert entered.exists(), "a compaction cleared the project the session entered" + assert not entered.exists()