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 d5a4b55..ea1f929 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,27 @@ 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. + +--- + +## 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. --- @@ -53,6 +71,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 +89,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..68e46ec --- /dev/null +++ b/hooks/kb-gate.sh @@ -0,0 +1,393 @@ +#!/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, and run housekeeping +# 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. +# `off` is the one exception, and it exits before reaching any of this. + +MODE="__KB_GATE_MODE__" + +# Anti-deadlock budgets for enforce mode. Sized by cost asymmetry: a denial is one +# cheap round-trip, a discovery agent running without KB findings is tens of +# thousands of tokens. Two orders of magnitude, so the aim is a guaranteed stop, +# not a tight leash. How the two differ is documented where they are counted. +MAX_DENIALS_PER_STATE=4 +MAX_DENIALS_PER_TURN=8 + +# Nothing outside this script prunes anything it writes, so both artifacts are +# bounded here. The log is a rolling diagnostic — only its recent tail is ever +# read — and grows by a line per prompt, search, gated spawn and sub-agent start: +# tens of MB a year in a project worked in daily. +# +# Keep the retained tail well clear of the cap (1000 lines is ~190 KB against +# 512 KB). If the two converge, a trim leaves the file still over the threshold +# and the rewrite fires every turn instead of every few thousand lines. +LOG_MAX_BYTES=524288 +LOG_KEEP_LINES=1000 + +# Per-session state is three small files that nothing ever removes. Bytes are not +# the problem — inodes are, at three per session forever. +STATE_MAX_AGE_DAYS=7 + +# A wire protocol: the string the messages below tell Claude to write, and the +# string tested for. The CLAUDE.local.md template carries its own copy, so +# changing one side alone silently disables the gate — the test suite asserts the +# two agree. +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 + +# 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) +[ -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}" + # 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" + 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 across strictness levels: switching warn↔observe↔enforce +# 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. budget_fields output +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")" +} + +# 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" +} + +# Called once per turn, never on the spawn path: the common case is one `wc`, and +# the rewrite only happens on the rare turn that crosses the threshold. Losing a +# line to a concurrent append during the swap is acceptable for a diagnostic log. +trim_log() { + [ -f "$LOG_FILE" ] || return 0 + # `wc` pads its output with leading spaces on BSD/macOS. Stripping non-digits + # is the whole guard: what survives is either digits or empty. + size=$(wc -c <"$LOG_FILE" 2>/dev/null) + size=${size//[^0-9]/} + [ -n "$size" ] || return 0 + [ "$size" -gt "$LOG_MAX_BYTES" ] || return 0 + # Temp file lives in the state dir, which is already gitignored, so a crash + # mid-rewrite cannot leave an untracked file in the user's working tree. `mv` + # unlinks the source, so one unconditional cleanup covers both failure paths. + tail -n "$LOG_KEEP_LINES" "$LOG_FILE" >"$STATE_DIR/log.tmp" 2>/dev/null && + mv "$STATE_DIR/log.tmp" "$LOG_FILE" 2>/dev/null + rm -f "$STATE_DIR/log.tmp" 2>/dev/null +} + +# Old sessions' state, swept on the first turn of a new session — the natural unit +# for "clean up after the ones that ended", and it keeps the fork off every turn. +# Current-session files are minutes old and never match. +sweep_state() { + find "$STATE_DIR" -type f -mtime "+$STATE_MAX_AGE_DAYS" -delete 2>/dev/null +} + +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 + turn=$(( $(current_turn) + 1 )) + # Turn 1 means no turn file existed, i.e. this session just started. + [ "$turn" = 1 ] && sweep_state + trim_log + : >"$DENIALS_FILE" 2>/dev/null || true + 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. The number of lines + # matching the current turn is also what stamps a denial's progress marker. + 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 + 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. + 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 + + # --- Skip guards, cheapest first --- + + # 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? 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 + [ "$qseen" -gt 0 ] && fresh_query=true + + if [ "$fresh_query" = true ] && [ "$had_kb_block" = true ]; then + log_decision true allow + exit 0 + fi + + # observe: recorded, but nothing is said to Claude. (off already exited above.) + [ "$MODE" = "observe" ] && { + log_decision false observe + exit 0 + } + + # 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. 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 "$(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. 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 + + # 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/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 6d2f70b..5d6c662 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,78 @@ 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 + # 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 + 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 + # 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 + 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,12 +267,15 @@ 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 # --------------------------------------------------------------------------- ignore: - SYNC-BLOCKS.md + - tests/ # --------------------------------------------------------------------------- # Templates — CLAUDE.local.md sections 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. diff --git a/tests/kb-gate-test.sh b/tests/kb-gate-test.sh new file mode 100755 index 0000000..6fb7b0a --- /dev/null +++ b/tests/kb-gate-test.sh @@ -0,0 +1,302 @@ +#!/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 nothing is ever gated. +# The denial count checked at the end is what turns that into a loud failure +# rather than a green run that asserted nothing. + +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)|LOG_(MAX_BYTES|KEEP_LINES))=' "$src")" +: "${MAX_DENIALS_PER_STATE:?not found in $src}" "${MAX_DENIALS_PER_TURN:?not found in $src}" \ + "${LOG_MAX_BYTES:?not found in $src}" "${LOG_KEEP_LINES:?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 +offproj="$work/offproj" # untouched except by mode=off, so "wrote nothing" is checkable +mkdir -p "$proj/.claude/memories" "$nokb/.claude" "$offproj/.claude/memories" +log="$proj/.claude/.kb-gate.log" +state="$proj/.claude/.kb-gate" + +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() { #