From 9874569c755bdd2c91f9ca60c21d7b6eb4d28f0e Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 28 Jul 2026 21:05:31 +0200 Subject: [PATCH 1/4] Gate delegated discovery behind a KB lookup - Add kb-gate.sh, one dispatcher on four hook events: it warns or blocks sub-agent spawns whose prompt carries no KB findings, and briefs discovery agents so they search the KB instead of sweeping files blind - Add a KB_GATE_MODE prompt (warn/enforce/observe/off, default warn) so each project picks its own strictness at sync time - Declare the ordering dependency in the template: the KB result is an input to the sub-agent prompt, which must open with a "KB context:" block --- README.md | 11 +- hooks/kb-gate.sh | 281 +++++++++++++++++++++++++++++++ techpack.yaml | 76 +++++++++ templates/continuous-learning.md | 21 +++ 4 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 hooks/kb-gate.sh diff --git a/README.md b/README.md index d5a4b55..5bc33f4 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ flowchart LR 1. **Session starts** — a hook re-indexes `.claude/memories/` into a local vector store (Ollama `nomic-embed-text`), in the background. 2. **Before any task** — Claude is instructed to search the knowledge base first, surfacing relevant past learnings and decisions. -3. **During work** — a prompt-submit hook reminds Claude to notice when the current interaction produces knowledge worth saving. -4. **After valuable work** — the `continuous-learning` skill extracts structured memories, checks for duplicates, and writes them to `.claude/memories/`. -5. **Next session** — the new memories are indexed and surfaced again. The loop compounds: debugging patterns aren't rediscovered, decisions aren't re-litigated, conventions aren't re-explained. +3. **Before delegating** — sub-agents can't see the parent's KB results, so they'd rediscover everything from scratch. A gate hook closes that gap from both ends: it requires the findings to be pasted into the sub-agent's prompt, and tells any discovery agent to search the KB itself if they weren't. Configurable per project, from a reminder up to a hard block. +4. **During work** — a prompt-submit hook reminds Claude to notice when the current interaction produces knowledge worth saving. +5. **After valuable work** — the `continuous-learning` skill extracts structured memories, checks for duplicates, and writes them to `.claude/memories/`. +6. **Next session** — the new memories are indexed and surfaced again. The loop compounds: debugging patterns aren't rediscovered, decisions aren't re-litigated, conventions aren't re-explained. --- @@ -53,6 +54,7 @@ flowchart LR | **memory-audit** (skill) | Reviews existing memories and flags stale or duplicate entries to keep the KB lean | | **sync-memories.sh** (hook) | Indexes/re-indexes memories on session start and when they change mid-session | | **continuous-learning-activator.sh** (hook) | Reminds Claude to check for extractable knowledge after each prompt | +| **kb-gate.sh** (hook) | Keeps the KB lookup ahead of delegated discovery: warns or blocks when a sub-agent is spawned without the findings in its prompt, and tells discovery sub-agents the KB exists so they search it instead of sweeping files blind | | `autoMemoryEnabled: false` (setting) | Disables Claude Code's built-in memory in favor of this system | Memories come in two flavors, both stored as version-controlled, human-readable markdown: @@ -70,7 +72,8 @@ memory/ ├── config/settings.json # Disables built-in auto-memory ├── hooks/ │ ├── sync-memories.sh # Ollama health + memory indexing/reindexing -│ └── continuous-learning-activator.sh # Knowledge extraction reminder +│ ├── continuous-learning-activator.sh # Knowledge extraction reminder +│ └── kb-gate.sh # Keeps KB lookups ahead of delegated discovery ├── skills/ │ ├── continuous-learning/ # Extraction rules + memory templates │ └── memory-audit/ # Audit workflow (KEEP/DROP/UPDATE) diff --git a/hooks/kb-gate.sh b/hooks/kb-gate.sh new file mode 100644 index 0000000..21c0452 --- /dev/null +++ b/hooks/kb-gate.sh @@ -0,0 +1,281 @@ +#!/bin/bash + +# Hook: keep KB lookups ahead of delegated discovery. +# +# One dispatcher registered on four events, branching on `hook_event_name`: +# UserPromptSubmit → stamp the turn boundary (resets the barrier each prompt) +# PostToolUse → record a successful search_docs query +# PreToolUse → judge a sub-agent spawn, and warn or block +# SubagentStart → tell a discovery sub-agent the KB exists +# +# Design rules, deliberate and load-bearing: +# - FAIL OPEN. Any unexpected state exits 0 with no output. A broken gate must +# never stop work. +# - NEVER exit 2. That blocks unconditionally, turning a script bug into a hard +# gate with no escape. This is also why `set -u`/`set -e` are omitted: an +# unset variable should fall through to a silent allow, not abort mid-branch. +# - NEVER call docs-mcp-server here. That CLI takes seconds; PreToolUse runs +# before every sub-agent spawn. File stats only. +# - Log every evaluation. Comparing log line count against the spawn count in a +# session transcript is the only way to catch a matcher that matches nothing. + +MODE="__KB_GATE_MODE__" + +# The prompt marker is a wire protocol shared with the `KB context:` contract in +# the CLAUDE.local.md template. Single definition: it is both the string the +# messages below instruct Claude to write, and the string tested for. +KB_MARKER="KB context:" + +# Single source of truth for "which agents do discovery". The SubagentStart and +# PreToolUse registrations both match broadly and let this list decide, so adding +# an agent type is a one-line change here. +GATED_AGENTS="Explore general-purpose Plan" + +command -v jq >/dev/null 2>&1 || exit 0 + +payload=$(/dev/null) +[ -n "$fields" ] || exit 0 + +{ + read -r event + read -r session_raw + read -r agent_id + read -r agent_type + read -r had_kb_block +} </dev/null) + [ -n "$project_root" ] || project_root="${CLAUDE_PROJECT_DIR:-$PWD}" + # Matches sync-memories.sh's derivation, so the library name quoted back to + # Claude is the one that was actually indexed. + library="${project_root##*/}" + MEMORIES_DIR="$project_root/.claude/memories" + LOG_FILE="$project_root/.claude/.kb-gate.log" + STATE_DIR="$project_root/.claude/.kb-gate" + # Session ids come from the harness, but sanitize anyway — this becomes a path + # segment. + session="${session_raw//[^A-Za-z0-9._-]/_}" + TURN_FILE="$STATE_DIR/$session.turn" + QUERIES_FILE="$STATE_DIR/$session.queries" + DENIALS_FILE="$STATE_DIR/$session.denials" +} + +# Only the branches that write state pay for the directory. +ensure_state() { + resolve_paths + [ -d "$STATE_DIR" ] || mkdir -p "$STATE_DIR" 2>/dev/null || return 1 +} + +# The barrier is scoped to a turn, so it needs a turn identity. A monotonic +# counter, never a timestamp: file mtimes and `date +%s` both have 1-second +# granularity, which makes "did this search happen during this turn?" ambiguous +# for anything occurring within the same second. +current_turn() { + # Guarded rather than relying on `2>/dev/null`: a failed input redirection is + # reported by the shell itself, so a missing file would print to stderr. + [ -f "$TURN_FILE" ] || { echo 0; return 0; } + read -r t <"$TURN_FILE" + case "$t" in + '' | *[!0-9]*) echo 0 ;; + *) echo "$t" ;; + esac +} + +# Logging is unconditional and mode-independent: a mode change must never leave a +# gap in the data. `$1` is a pre-built JSON object of extra fields. +log_event() { + resolve_paths + jq -nc \ + --arg session "$session" \ + --arg event "$event" \ + --arg mode "$MODE" \ + --argjson extra "$1" \ + '{ts:(now|todate),session:$session,event:$event,mode:$mode} + $extra' \ + >>"$LOG_FILE" 2>/dev/null || true +} + +# Decision records share a shape, so build it with printf — every value is either +# whitelisted (agent_type, phase), a literal boolean, or an integer. Keeps a +# second jq off the hot path. +# $1=satisfied $2=decision $3=optional trailing pairs, e.g. ',"attempt":2' +log_decision() { + log_event "$(printf \ + '{"phase":"%s","agent_type":"%s","fresh_query":%s,"had_kb_block":%s,"satisfied":%s,"decision":"%s"%s}' \ + "$phase" "$agent_type" "$fresh_query" "$had_kb_block" "$1" "$2" "$3")" +} + +is_gated_agent() { + case " $GATED_AGENTS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +case "$event" in +UserPromptSubmit) + # New turn: reset the barrier and clear this turn's denial history. + ensure_state || exit 0 + : >"$DENIALS_FILE" 2>/dev/null || true + turn=$(( $(current_turn) + 1 )) + echo "$turn" >"$TURN_FILE" 2>/dev/null || true + log_event "$(printf '{"decision":"turn_start","turn":%s}' "$turn")" + ;; + +PostToolUse) + # A KB search landed. Store the query text (not just the turn) so the barrier + # can become topical later without a state migration. + query=$(printf '%s' "$payload" | jq -r '.tool_input.query // ""' 2>/dev/null) + [ -n "$query" ] || exit 0 + ensure_state || exit 0 + printf '%s\t%s\n' "$(current_turn)" "$query" >>"$QUERIES_FILE" 2>/dev/null || true + log_event "$(jq -nc --arg q "$query" '{decision:"recorded",query:$q}')" + ;; + +SubagentStart) + # Cannot block — injects context only, which is all that's needed. The parent + # often spawns agents in the same tool block as its own KB search, so the + # agents never receive the findings; this makes each agent self-sufficient + # rather than depending on the parent getting the order right. + [ "$MODE" = "off" ] && exit 0 + is_gated_agent "$agent_type" || exit 0 + resolve_paths + [ -d "$MEMORIES_DIR" ] || exit 0 + + # Cost is bounded on purpose: skip the search entirely when the prompt already + # carries findings, and spend at most one query otherwise. SubagentStart input + # does not include the prompt, so the agent has to make that call itself. + jq -nc --arg e "$event" --arg m "$KB_MARKER" --arg lib "$library" '{ + hookSpecificOutput: { + hookEventName: $e, + additionalContext: ( + "This project keeps a knowledge base of past learnings, decisions, and\n" + + "debugging discoveries in .claude/memories/, searchable with\n" + + "mcp__docs-mcp-server__search_docs using library=\"" + $lib + "\".\n\n" + + "- If your prompt contains a \"" + $m + "\" block, treat it as established\n" + + " ground truth. Verify it against the code, but do NOT search the KB and\n" + + " do NOT re-derive it.\n" + + "- Otherwise, if you are about to read or grep more than a couple of files,\n" + + " issue ONE search_docs query for the topic of your task first — one search\n" + + " is far cheaper than a blind file sweep. Unlike the main thread, do not try\n" + + " keyword variations: if nothing relevant comes back, move on to the code.\n" + + "- Report back anything the KB got wrong or left out." + ) + } + }' + log_event "$(printf '{"agent_type":"%s","decision":"briefed"}' "$agent_type")" + ;; + +PreToolUse) + phase=discovery + + # --- Fail-open guards. All use already-parsed fields, so they cost nothing --- + + # PreToolUse also fires for tool calls made *inside* sub-agents. Judging those + # against the parent's markers would gate an agent for its parent's omission. + [ -z "$agent_id" ] || { + log_event '{"decision":"skip","skip_reason":"nested_subagent"}' + exit 0 + } + + # code-reviewer, statusline-setup, the `claude` catch-all: usually + # non-discovery work where a gate is pure friction. + is_gated_agent "$agent_type" || { + log_event "$(printf \ + '{"agent_type":"%s","decision":"skip","skip_reason":"agent_type_not_gated"}' \ + "$agent_type")" + exit 0 + } + + resolve_paths + # No KB to search — never nag about a knowledge base that doesn't exist. + [ -d "$MEMORIES_DIR" ] || { + log_event '{"decision":"skip","skip_reason":"no_memories_dir"}' + exit 0 + } + + # --- Evaluate the two halves of the barrier --- + + # Ordering: was a KB search recorded during *this* turn? + turn=$(current_turn) + fresh_query=false + if [ -f "$QUERIES_FILE" ] && awk -F'\t' -v t="$turn" \ + '$1 == t { found = 1 } END { exit !found }' "$QUERIES_FILE" 2>/dev/null; then + fresh_query=true + fi + + if [ "$fresh_query" = true ] && [ "$had_kb_block" = true ]; then + log_decision true allow + exit 0 + fi + + case "$MODE" in + off | observe) + # Recorded, but nothing is said to Claude. + log_decision false "$MODE" + exit 0 + ;; + esac + + # Name whichever half failed, so the message is actionable. + missing="" + [ "$fresh_query" = false ] && missing="no search_docs call has run since this turn began" + [ "$had_kb_block" = false ] && + missing="${missing:+$missing, and }the prompt has no \"$KB_MARKER\" block" + + if [ "$MODE" = "enforce" ]; then + # Anti-deadlock: allow through on the second denial of this phase in this + # turn. Whatever the cause — an unsatisfiable condition, a rephrasing + # loop, a bug — the worst case is one wasted call, never a hang. Keyed by + # phase so a denied spawn cannot burn the budget for an unrelated gate. + attempt=$(( $(awk -F'\t' -v t="$turn" -v p="$phase" \ + '$1 == t && $2 == p { n++ } END { print n + 0 }' \ + "$DENIALS_FILE" 2>/dev/null || echo 0) + 1 )) + if [ "$attempt" -ge 2 ]; then + log_decision false allow ',"skip_reason":"attempt_limit","attempt":'"$attempt" + exit 0 + fi + printf '%s\t%s\n' "$turn" "$phase" >>"$DENIALS_FILE" 2>/dev/null || true + log_decision false deny ',"attempt":'"$attempt" + + # Phrased as a missing prerequisite plus an explicit retry instruction. A + # bare denial reads as "the user declined" and makes Claude abandon the + # path instead of satisfying the requirement. + jq -nc --arg e "$event" --arg r "Prerequisite missing: $missing. Call search_docs(library=\"$library\", query=\"\") first, then re-issue this exact call with a \"$KB_MARKER\" block (1-5 bullets of findings, or \"$KB_MARKER none relevant.\") at the top of the prompt. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch." \ + '{hookSpecificOutput:{hookEventName:$e,permissionDecision:"deny",permissionDecisionReason:$r}}' + exit 0 + fi + + # warn: advise without blocking. No permissionDecision field — returning + # "allow" would bypass the normal permission flow. + log_decision false warn + jq -nc --arg e "$event" --arg m "KB protocol: $missing. Sub-agents cannot see your KB results, so this agent will rediscover from scratch. Before the next spawn, search the KB (library=\"$library\") and open the prompt with a \"$KB_MARKER\" block." \ + '{hookSpecificOutput:{hookEventName:$e,additionalContext:$m}}' + ;; +esac + +exit 0 diff --git a/techpack.yaml b/techpack.yaml index 6d2f70b..6590636 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -5,6 +5,24 @@ description: Persistent memory and knowledge management for Claude Code author: Bruno Guidolim minMCSVersion: "2026.4.12" +# --------------------------------------------------------------------------- +# Prompts — asked once per project during `mcs sync`, then remembered +# --------------------------------------------------------------------------- +prompts: + - key: KB_GATE_MODE + type: select + label: "KB gate — how strictly must Claude check the KB before delegating?" + default: "warn" + options: + - value: "warn" + label: "Warn — remind and log when a sub-agent is spawned without KB findings" + - value: "enforce" + label: "Enforce — block the spawn until the KB has been searched this turn" + - value: "observe" + label: "Observe — never gate the main thread, just log (sub-agents are still briefed)" + - value: "off" + label: "Off — no gating and no sub-agent briefing; prompt reminder only" + # --------------------------------------------------------------------------- # Components # --------------------------------------------------------------------------- @@ -166,6 +184,62 @@ components: source: hooks/sync-memories.sh destination: sync-memories.sh + # ── KB-first delegation ───────────────────────────────────────────────── + # Four registrations of one dispatcher script, which branches on + # `hook_event_name`. Together they keep KB lookups ahead of delegated discovery, + # so sub-agents don't rediscover what a memory already recorded. Matchers stay + # broad on purpose — which agent types count as "discovery" is decided in one + # place, by `GATED_AGENTS` in the script. + - id: hook-kb-gate-turn + displayName: KB gate — turn boundary + description: Marks each new prompt so the KB check is required again per turn + dependencies: [jq] + hookEvent: UserPromptSubmit + hook: + source: hooks/kb-gate.sh + destination: kb-gate.sh + + - id: hook-kb-gate-record + displayName: KB gate — record searches + description: Records each KB search so delegated discovery can be checked against it + dependencies: [jq] + hookEvent: PostToolUse + hookMatcher: "mcp__docs-mcp-server__search_docs" + hook: + source: hooks/kb-gate.sh + destination: kb-gate.sh + + - id: hook-kb-gate-check + displayName: KB gate — check sub-agent spawns + description: Warns or blocks when a discovery sub-agent is spawned without KB findings in its prompt + dependencies: [jq] + hookEvent: PreToolUse + # The spawn tool is `Agent` in current Claude Code and `Task` in older + # builds. Matching only `Task` silently matches nothing. + hookMatcher: "Agent|Task" + hook: + source: hooks/kb-gate.sh + destination: kb-gate.sh + doctorChecks: + - type: hookEventExists + name: "PreToolUse hook registered" + section: Hooks + event: PreToolUse + + - id: hook-kb-subagent-brief + displayName: Sub-agent KB brief + description: Tells discovery sub-agents that the KB exists, so they search it instead of sweeping files + dependencies: [jq] + hookEvent: SubagentStart + hook: + source: hooks/kb-gate.sh + destination: kb-gate.sh + doctorChecks: + - type: hookEventExists + name: "SubagentStart hook registered" + section: Hooks + event: SubagentStart + # ── Configuration ─────────────────────────────────────────────────────── - id: settings displayName: Settings @@ -177,6 +251,8 @@ components: description: Ignores memory files from version control gitignore: - ".claude/.memories-last-indexed" + - ".claude/.kb-gate/" + - ".claude/.kb-gate.log" # --------------------------------------------------------------------------- # Ignore — maintainer-only files not shipped with the pack diff --git a/templates/continuous-learning.md b/templates/continuous-learning.md index a98b44b..ce3e592 100644 --- a/templates/continuous-learning.md +++ b/templates/continuous-learning.md @@ -19,6 +19,27 @@ Search the KB again **before starting** whenever the work shifts to a new phase, Past sessions often contain decisions and patterns that prevent unnecessary iterations and PR comments. +### Delegation barrier + +`search_docs` and any sub-agent spawn (the `Agent` / `Task` tool) are **not** independent calls — +the KB result is an *input* to the sub-agent's prompt. Never place them in the same message, and +never spawn a sub-agent before you have read the KB results. + +Every sub-agent prompt that reads or searches this codebase must open with: + +``` +KB context: <1-5 bullets — file paths, patterns, gotchas, decisions from the KB> +``` + +or the literal line `KB context: none relevant.` + +Two rules that save the most work: + +- **If the KB already names the files, read them directly.** Do not spawn an agent to re-find + what a memory already told you. The cheapest outcome is no agent at all. +- **Fanning out to 3+ agents?** Write the findings once to a scratchpad file and point each agent + at it — but keep the opener, as `KB context: see `. The block is required either way. + ## Referencing memories in shared artifacts Memory files are a project-internal KB — filenames drift as files are renamed or merged, and not all readers have repo access. **Never cite memory filenames** in commits, PR descriptions, issue trackers, chat, code comments, docstrings, or release notes — whether or not `.claude/memories/` is tracked in git. From 961e3ccb29a1ff6928d6f466236ce8c09cfdbca0 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 29 Jul 2026 10:48:55 +0200 Subject: [PATCH 2/4] Budget KB gate denials by progress, not by turn - Deny every spawn in a fan-out and keep gating after a denial; release only once a per-state or per-turn budget is spent - Add a test suite and CI, plus doctor checks for the two registrations whose absence corrupts the barrier rather than disabling it - Make `off` a true kill switch and document the modes in the README --- .github/workflows/kb-gate-test.yml | 34 ++++ README.md | 17 ++ hooks/kb-gate.sh | 130 ++++++++++++---- hooks/sync-memories.sh | 3 + techpack.yaml | 17 ++ tests/kb-gate-test.sh | 239 +++++++++++++++++++++++++++++ 6 files changed, 409 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/kb-gate-test.yml create mode 100755 tests/kb-gate-test.sh diff --git a/.github/workflows/kb-gate-test.yml b/.github/workflows/kb-gate-test.yml new file mode 100644 index 0000000..2ba201b --- /dev/null +++ b/.github/workflows/kb-gate-test.yml @@ -0,0 +1,34 @@ +name: kb-gate-test + +on: + pull_request: + paths: + - "hooks/kb-gate.sh" + - "tests/kb-gate-test.sh" + - ".github/workflows/kb-gate-test.yml" + push: + branches: [main] + paths: + - "hooks/kb-gate.sh" + - "tests/kb-gate-test.sh" + +permissions: + contents: read + +concurrency: + group: kb-gate-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # Twice, deliberately: the barrier is scoped by a monotonic turn counter + # rather than by wall-clock time, and a regression to timestamps would show + # up as a second run behaving differently from the first. + - name: Run the KB gate suite twice + run: | + bash tests/kb-gate-test.sh + bash tests/kb-gate-test.sh diff --git a/README.md b/README.md index 5bc33f4..ea1f929 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,23 @@ flowchart LR --- +## Gate modes + +Installing this pack asks one question: how strictly the gate in step 3 should hold the rule. It's the only prompt in the pack, and the answer is remembered at sync time. + +| Mode | When a discovery sub-agent is spawned without KB findings | Pick it when | +|---|---|---| +| `warn` *(default)* | Claude gets a reminder; the agent runs anyway | You want the nudge, never an interruption | +| `enforce` | The spawn is denied, naming what's missing and how to retry | You want the rule actually held | +| `observe` | Nothing is said, the decision is logged | You want to see how often it would fire before switching it on | +| `off` | Nothing at all — no gating, no sub-agent briefing | You've opted out; the instruction stays in `CLAUDE.local.md`, nothing checks it | + +`enforce` cannot wedge a session: denials are budgeted per turn, so a spawn always gets through eventually even if the rule is never satisfied. Every mode except `off` also briefs discovery sub-agents at startup and records its decisions to `.claude/.kb-gate.log`. + +To change the answer later, re-run `mcs sync` — the mode is baked into the installed hook, not read from a setting at runtime. + +--- + ## What's included | Component | What it does | diff --git a/hooks/kb-gate.sh b/hooks/kb-gate.sh index 21c0452..4c78ee8 100644 --- a/hooks/kb-gate.sh +++ b/hooks/kb-gate.sh @@ -21,6 +21,18 @@ MODE="__KB_GATE_MODE__" +# Anti-deadlock budgets for enforce mode, both keyed by turn AND phase. Sized by +# cost asymmetry: a denial is one cheap round-trip, a discovery agent that runs +# without KB findings is tens of thousands of tokens. Two orders of magnitude, so +# the aim is a guaranteed stop, not a tight leash. +# +# The first resets whenever a KB search lands, so complying re-arms the gate for +# the rest of the turn; it is wide enough that a parallel fan-out is denied in +# full rather than leaking every spawn after the first. The second never resets +# within a turn, which is what makes termination unconditional. +MAX_DENIALS_PER_STATE=4 +MAX_DENIALS_PER_TURN=8 + # The prompt marker is a wire protocol shared with the `KB context:` contract in # the CLAUDE.local.md template. Single definition: it is both the string the # messages below instruct Claude to write, and the string tested for. @@ -33,6 +45,11 @@ GATED_AGENTS="Explore general-purpose Plan" command -v jq >/dev/null 2>&1 || exit 0 +# Off is a kill switch, not the quietest strictness level — `observe` is already +# "never gate, just log". So off means off: no state directory, no log lines, no +# work at all, for a user who opted out. +[ "$MODE" = "off" ] && exit 0 + payload=$(/dev/null) +] | .[]' <<<"$payload" 2>/dev/null) [ -n "$fields" ] || exit 0 { @@ -65,14 +82,17 @@ fields=$(printf '%s' "$payload" | jq -r --arg m "$KB_MARKER" '[ $fields EOF -# Paths are resolved on demand: the skip guards below need none of them, and -# `git rev-parse` is the most expensive call in the script. +# Paths are resolved on demand, and `git rev-parse` is the most expensive call in +# the script. Note this only pays off on the two SubagentStart exits that precede +# any logging — every other branch logs, and logging needs the project root, so a +# skip costs the same as a decision. Keep it that way: the unconditional log is +# worth more than the fork. resolve_paths() { [ -n "$project_root" ] && return 0 project_root=$(git rev-parse --show-toplevel 2>/dev/null) [ -n "$project_root" ] || project_root="${CLAUDE_PROJECT_DIR:-$PWD}" - # Matches sync-memories.sh's derivation, so the library name quoted back to - # Claude is the one that was actually indexed. + # keep in sync with hooks/sync-memories.sh project root + library derivation — + # the library name quoted back to Claude has to be the one that was indexed. library="${project_root##*/}" MEMORIES_DIR="$project_root/.claude/memories" LOG_FILE="$project_root/.claude/.kb-gate.log" @@ -129,6 +149,12 @@ log_decision() { "$phase" "$agent_type" "$fresh_query" "$had_kb_block" "$1" "$2" "$3")" } +# The trailing pairs every enforce-mode decision carries. One definition, so +# renaming a key or adding a counter is a single edit. +budget_fields() { + printf ',"denials_turn":%s,"denials_state":%s' "$1" "$2" +} + is_gated_agent() { case " $GATED_AGENTS " in *" $1 "*) return 0 ;; @@ -149,7 +175,7 @@ UserPromptSubmit) PostToolUse) # A KB search landed. Store the query text (not just the turn) so the barrier # can become topical later without a state migration. - query=$(printf '%s' "$payload" | jq -r '.tool_input.query // ""' 2>/dev/null) + query=$(jq -r '.tool_input.query // ""' <<<"$payload" 2>/dev/null) [ -n "$query" ] || exit 0 ensure_state || exit 0 printf '%s\t%s\n' "$(current_turn)" "$query" >>"$QUERIES_FILE" 2>/dev/null || true @@ -161,7 +187,6 @@ SubagentStart) # often spawns agents in the same tool block as its own KB search, so the # agents never receive the findings; this makes each agent self-sufficient # rather than depending on the parent getting the order right. - [ "$MODE" = "off" ] && exit 0 is_gated_agent "$agent_type" || exit 0 resolve_paths [ -d "$MEMORIES_DIR" ] || exit 0 @@ -220,26 +245,31 @@ PreToolUse) # --- Evaluate the two halves of the barrier --- - # Ordering: was a KB search recorded during *this* turn? + # Ordering: was a KB search recorded during *this* turn? Counted rather than + # merely tested, because the same number does double duty below as the + # progress stamp on a denial — "how much had Claude searched when this was + # written". A later denial carrying a different count means a search landed + # in between. turn=$(current_turn) + qseen=0 + [ -f "$QUERIES_FILE" ] && qseen=$(awk -F'\t' -v t="$turn" \ + '$1 == t { n++ } END { print n + 0 }' "$QUERIES_FILE" 2>/dev/null) + case "$qseen" in + '' | *[!0-9]*) qseen=0 ;; + esac fresh_query=false - if [ -f "$QUERIES_FILE" ] && awk -F'\t' -v t="$turn" \ - '$1 == t { found = 1 } END { exit !found }' "$QUERIES_FILE" 2>/dev/null; then - fresh_query=true - fi + [ "$qseen" -gt 0 ] && fresh_query=true if [ "$fresh_query" = true ] && [ "$had_kb_block" = true ]; then log_decision true allow exit 0 fi - case "$MODE" in - off | observe) - # Recorded, but nothing is said to Claude. - log_decision false "$MODE" + # observe: recorded, but nothing is said to Claude. (off already exited above.) + [ "$MODE" = "observe" ] && { + log_decision false observe exit 0 - ;; - esac + } # Name whichever half failed, so the message is actionable. missing="" @@ -248,24 +278,62 @@ PreToolUse) missing="${missing:+$missing, and }the prompt has no \"$KB_MARKER\" block" if [ "$MODE" = "enforce" ]; then - # Anti-deadlock: allow through on the second denial of this phase in this - # turn. Whatever the cause — an unsatisfiable condition, a rephrasing - # loop, a bug — the worst case is one wasted call, never a hang. Keyed by - # phase so a denied spawn cannot burn the budget for an unrelated gate. - attempt=$(( $(awk -F'\t' -v t="$turn" -v p="$phase" \ - '$1 == t && $2 == p { n++ } END { print n + 0 }' \ - "$DENIALS_FILE" 2>/dev/null || echo 0) + 1 )) - if [ "$attempt" -ge 2 ]; then - log_decision false allow ',"skip_reason":"attempt_limit","attempt":'"$attempt" + # Anti-deadlock. Two budgets from one append-only file: each denial is + # stamped with the search count at the time it was written, so counting + # lines two ways answers two different questions. Only appends, no + # read-modify-write — several of these can run at once when a batch of + # spawns arrives together, and only an append is atomic. + # + # denials_state — denials since the last KB search. A search changes + # $qseen, which retires the old stamps and re-arms the + # gate, so complying mid-turn is rewarded rather than + # spending budget. + # denials_turn — every denial this turn, never reset. This is the one + # that guarantees termination: whatever the cause of a + # loop, it stops here. + # + # Both keyed by phase, so a denied spawn cannot burn the budget of an + # unrelated gate. + denials_turn=0 + denials_state=0 + if [ -f "$DENIALS_FILE" ]; then + # Guarded, not just `2>/dev/null`: awk on a missing file exits 2 and + # prints nothing, leaving both counters empty — which would fail + # *closed*, the one direction this script must never fail in. + counts=$(awk -F'\t' -v t="$turn" -v p="$phase" -v q="$qseen" \ + '$1 == t && $2 == p { total++; if (($3 + 0) == q) same++ } + END { print total + 0, same + 0 }' "$DENIALS_FILE" 2>/dev/null) + read -r denials_turn denials_state <>"$DENIALS_FILE" 2>/dev/null || true - log_decision false deny ',"attempt":'"$attempt" + + # Tested in this order so the turn ceiling wins the reason when both are + # spent — it is the one that makes termination unconditional. + budget_spent="" + [ "$denials_state" -ge "$MAX_DENIALS_PER_STATE" ] && budget_spent=budget_no_progress + [ "$denials_turn" -ge "$MAX_DENIALS_PER_TURN" ] && budget_spent=budget_turn + if [ -n "$budget_spent" ]; then + log_decision false allow ',"skip_reason":"'"$budget_spent"'"'"$(budget_fields "$denials_turn" "$denials_state")" + exit 0 + fi + + printf '%s\t%s\t%s\n' "$turn" "$phase" "$qseen" >>"$DENIALS_FILE" 2>/dev/null || true + log_decision false deny "$(budget_fields "$((denials_turn + 1))" "$((denials_state + 1))")" # Phrased as a missing prerequisite plus an explicit retry instruction. A # bare denial reads as "the user declined" and makes Claude abandon the # path instead of satisfying the requirement. - jq -nc --arg e "$event" --arg r "Prerequisite missing: $missing. Call search_docs(library=\"$library\", query=\"\") first, then re-issue this exact call with a \"$KB_MARKER\" block (1-5 bullets of findings, or \"$KB_MARKER none relevant.\") at the top of the prompt. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch." \ + jq -nc --arg e "$event" --arg r "Prerequisite missing: $missing. Call search_docs(library=\"$library\", query=\"\") first, then re-issue this exact call with a \"$KB_MARKER\" block (1-5 bullets of findings, or \"$KB_MARKER none relevant.\") at the top of the prompt. Spawning several agents at once: write the findings to a scratchpad file and open each prompt with \"$KB_MARKER see \" instead of repeating them. Sub-agents cannot see your KB results — unpasted context is rediscovered from scratch." \ '{hookSpecificOutput:{hookEventName:$e,permissionDecision:"deny",permissionDecisionReason:$r}}' exit 0 fi diff --git a/hooks/sync-memories.sh b/hooks/sync-memories.sh index bcb1ab4..a406be4 100755 --- a/hooks/sync-memories.sh +++ b/hooks/sync-memories.sh @@ -12,6 +12,9 @@ cat >/dev/null 2>&1 || true # anchor so launching from any subdirectory of a repo maps to the same library. # In a non-git folder git exits non-zero (empty output); fall back to where # Claude was launched (CLAUDE_PROJECT_DIR, else $PWD). +# +# keep in sync with hooks/kb-gate.sh resolve_paths() — that hook quotes the +# library name back to Claude, and it has to be the one indexed here. project_root=$(git rev-parse --show-toplevel 2>/dev/null) [ -n "$project_root" ] || project_root="${CLAUDE_PROJECT_DIR:-$PWD}" diff --git a/techpack.yaml b/techpack.yaml index 6590636..5d6c662 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -198,6 +198,15 @@ components: hook: source: hooks/kb-gate.sh destination: kb-gate.sh + # Checked because losing this one corrupts the gate rather than disabling it: + # with no turn stamp the counter reads 0 forever, so every search ever made in + # the session counts as fresh and the barrier silently widens from per-turn to + # per-session. + doctorChecks: + - type: hookEventExists + name: "UserPromptSubmit hook registered" + section: Hooks + event: UserPromptSubmit - id: hook-kb-gate-record displayName: KB gate — record searches @@ -208,6 +217,13 @@ components: hook: source: hooks/kb-gate.sh destination: kb-gate.sh + # Same reasoning: without this, no search is ever recorded and the gate is + # pure friction until the budget releases it. + doctorChecks: + - type: hookEventExists + name: "PostToolUse hook registered" + section: Hooks + event: PostToolUse - id: hook-kb-gate-check displayName: KB gate — check sub-agent spawns @@ -259,6 +275,7 @@ components: # --------------------------------------------------------------------------- ignore: - SYNC-BLOCKS.md + - tests/ # --------------------------------------------------------------------------- # Templates — CLAUDE.local.md sections diff --git a/tests/kb-gate-test.sh b/tests/kb-gate-test.sh new file mode 100755 index 0000000..7f18c02 --- /dev/null +++ b/tests/kb-gate-test.sh @@ -0,0 +1,239 @@ +#!/bin/bash +# +# Tests for hooks/kb-gate.sh. +# +# The dispatcher's branches are driven directly with crafted JSON rather than +# through a live session: deterministic, no clock, no transcript to stage. +# +# CI runs this file twice per push on purpose. The barrier is scoped by a +# monotonic turn counter rather than wall-clock time, and a regression to +# timestamps shows up as the second run behaving differently from the first — so +# run it twice locally too when touching the state handling. +# +# Two setup details are load-bearing, not incidental: +# - Everything runs from a temp dir OUTSIDE any git repo. The hook resolves its +# project root with `git rev-parse --show-toplevel` first, so a harness run +# from the checkout would write state into the working tree and read the +# developer's real session files. +# - The fixture project must contain .claude/memories/. Without it every +# PreToolUse call takes the `no_memories_dir` skip and the whole suite passes +# vacuously. The final denial-count check is what makes that failure loud. + +set -uo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +src="$repo_root/hooks/kb-gate.sh" +template="$repo_root/templates/continuous-learning.md" +[ -f "$src" ] || { + echo "FATAL: $src not found" + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "FATAL: jq is required" + exit 1 +} + +# Budgets come from the script, never re-typed here: retuning them is a routine +# edit with no correctness risk, and it should not break the suite. +eval "$(grep -E '^MAX_DENIALS_PER_(STATE|TURN)=' "$src")" +: "${MAX_DENIALS_PER_STATE:?not found in $src}" "${MAX_DENIALS_PER_TURN:?not found in $src}" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# One copy per mode, with the sync-time placeholder substituted. +for m in enforce warn observe off; do + sed "s/__KB_GATE_MODE__/$m/" "$src" >"$work/kb-gate-$m.sh" +done + +proj="$work/proj" # a project with a KB +nokb="$work/nokb" # a project without one +mkdir -p "$proj/.claude/memories" "$nokb/.claude" + +pass=0 +fail=0 +denies=0 +sid_n=0 +sid="" +mode=enforce + +ok() { + pass=$((pass + 1)) + printf ' ok %s\n' "$1" +} + +bad() { + fail=$((fail + 1)) + printf ' FAIL %s\n %s\n' "$1" "$2" +} + +group() { printf '\n== %s\n' "$1"; } + +# A fresh session id is what isolates a group's state; every group starts on +# turn 1, so the turn stamp belongs here rather than in each caller. +new_session() { + sid_n=$((sid_n + 1)) + sid="session-$sid_n" + turn +} + +# --- event payloads ------------------------------------------------------- + +j_turn() { jq -nc --arg s "$sid" '{hook_event_name:"UserPromptSubmit",session_id:$s}'; } + +j_search() { + jq -nc --arg s "$sid" --arg q "${1:-some topic}" \ + '{hook_event_name:"PostToolUse",session_id:$s,tool_input:{query:$q}}' +} + +# j_spawn [prompt] [agent_type] [agent_id] +j_spawn() { + jq -nc --arg s "$sid" --arg p "${1:-find the thing}" \ + --arg a "${2:-Explore}" --arg id "${3:-}" \ + '{hook_event_name:"PreToolUse",session_id:$s,agent_id:$id, + tool_input:{subagent_type:$a,prompt:$p}}' +} + +# --- invocation ----------------------------------------------------------- + +# run +run() { + (cd "$2" && printf '%s' "$3" | env -u CLAUDE_PROJECT_DIR bash "$work/kb-gate-$1.sh") +} + +turn() { run "$mode" "$proj" "$(j_turn)" >/dev/null; } +search() { run "$mode" "$proj" "$(j_search "$@")" >/dev/null; } +spawn() { run "$mode" "$proj" "$(j_spawn "$@")"; } + +KB="KB context: - hooks/kb-gate.sh holds the policy" + +# --- assertions ----------------------------------------------------------- + +assert_contains() { #