From 7a557f73c72b1210962a12fe5548b5a39b08726c Mon Sep 17 00:00:00 2001 From: Regis <92858615+Regis-RCR@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:53:07 +0200 Subject: [PATCH] Add slash commands, an opt-in enforce hook, and user directives Adds four /mt-* slash commands (onboard, preflight, recall, impact), an opt-in PreToolUse enforce hook shipped as hooks.json.example (not auto-registered), an advisory Stop backstop, and two directive docs. All sit on top of the consolidated skills catalogue without changing any skill. See the PR description for the full gap analysis. --- .../memtrace-skills/INSTALLABLE-DIRECTIVE.md | 67 +++++ plugins/memtrace-skills/MEMTRACE.md | 73 ++++++ plugins/memtrace-skills/commands/mt-impact.md | 50 ++++ .../memtrace-skills/commands/mt-onboard.md | 50 ++++ .../memtrace-skills/commands/mt-preflight.md | 53 ++++ plugins/memtrace-skills/commands/mt-recall.md | 53 ++++ .../memtrace-skills/hooks/hooks.json.example | 28 ++ .../hooks/memtrace-enforce-pretooluse.sh | 244 ++++++++++++++++++ .../hooks/memtrace-enforce-stop.sh | 155 +++++++++++ 9 files changed, 773 insertions(+) create mode 100644 plugins/memtrace-skills/INSTALLABLE-DIRECTIVE.md create mode 100644 plugins/memtrace-skills/MEMTRACE.md create mode 100644 plugins/memtrace-skills/commands/mt-impact.md create mode 100644 plugins/memtrace-skills/commands/mt-onboard.md create mode 100644 plugins/memtrace-skills/commands/mt-preflight.md create mode 100644 plugins/memtrace-skills/commands/mt-recall.md create mode 100644 plugins/memtrace-skills/hooks/hooks.json.example create mode 100755 plugins/memtrace-skills/hooks/memtrace-enforce-pretooluse.sh create mode 100755 plugins/memtrace-skills/hooks/memtrace-enforce-stop.sh diff --git a/plugins/memtrace-skills/INSTALLABLE-DIRECTIVE.md b/plugins/memtrace-skills/INSTALLABLE-DIRECTIVE.md new file mode 100644 index 0000000..17eaf1c --- /dev/null +++ b/plugins/memtrace-skills/INSTALLABLE-DIRECTIVE.md @@ -0,0 +1,67 @@ +# Installable directive: put the Memtrace reflex in the agent's context + +> The plugin ships skills, commands, a hook, and an MCP server-instruction block. Two +> of those carry the "graph before grep" reflex only conditionally: the hook is +> scoped to indexed repos and can be opted out, and the injected MCP block can be +> truncated in a long session. To GUARANTEE the reflex is present, add the compact +> directive below to the agent's context, the same way you would add any standing +> instruction to `CLAUDE.md`. Do this AFTER installing the plugin (the directive +> names tools the plugin provides). It is optional but recommended. + +## When to install it + +- You want the memtrace-first reflex even where the hook is off (`MEMTRACE_ENFORCE=off`) + or absent (a build without the hook). +- You want the recall-before-edit and impact-before-edit reflexes stated once, in + context, rather than relying on each skill's description being ranked correctly. +- You run long sessions where the injected MCP block may be truncated. + +## Form A: a block for `CLAUDE.md` (project or global) + +Paste this into your project `CLAUDE.md`, or your global `~/.claude/CLAUDE.md`, under +a heading of your choice. It is plain guidance, no tool of its own. + +```md +## Memtrace: graph before grep + +In a repository Memtrace has indexed: + +- Code discovery goes through the graph first. To locate a symbol, find callers, or + map a subsystem, call `find_code` / `find_symbol` / `get_symbol_context` before + `grep` / `rg` / `git grep`. A 0 result means broaden the query or reindex, not fall + back to grep. +- Blast radius before an edit. Before changing an existing symbol, call `get_impact` + (or the `/mt-preflight` command) rather than hunting references by hand. +- Recall before you remove. Before deleting or refactoring existing code you did not + just write, query decision memory (`recall_decision` / `why_is_this_here` / + `governing_contracts`, or `/mt-recall`) so a ban or contract is not silently broken. +- History from the graph. For what changed and when, prefer `get_evolution` / + `get_timeline` / `get_changes_since` over `git log` / `git diff`. + +Unaffected: filename globbing (`find`, a name glob), config / data / docs, a plain or +piped single-file `grep`, and any path outside an indexed repo. This is about code +content, not text filtering. +``` + +## Form B: a rules file (for setups that use `~/.claude/rules/`) + +Some setups load standing instructions from per-topic files instead of `CLAUDE.md`. +For those, save the SAME block above as `~/.claude/rules/memtrace.md` (drop the `##` +heading line if your loader adds its own). One topic per file; this file's topic is +the graph-before-grep reflex. + +## Optional: let the installer offer it + +A future `memtrace install` could OFFER (never silently write) to append Form A to a +`CLAUDE.md` the user names, or to drop Form B into `~/.claude/rules/`, with an explicit +prompt and an easy removal path. Writing to a user's `CLAUDE.md` or rules directory is +the user's decision, so an installer should present the block and let the user place +it, not modify config unprompted. + +## Relationship to the other directive surfaces + +This installable block is the always-in-context form of the same doctrine that +`MEMTRACE.md` states in full and that `SERVER-INSTRUCTIONS.md` injects via the MCP +server. Installing it is belt-and-suspenders: the hook enforces deterministically +where it runs, the MCP block nudges each session, and this block guarantees the reflex +is in context regardless. All three are opt-out friendly. diff --git a/plugins/memtrace-skills/MEMTRACE.md b/plugins/memtrace-skills/MEMTRACE.md new file mode 100644 index 0000000..d9bce04 --- /dev/null +++ b/plugins/memtrace-skills/MEMTRACE.md @@ -0,0 +1,73 @@ +# MEMTRACE.md: the plugin's top-level directive (precursor) + +> The doctrinal precursor of the memtrace/memfleet plugin. The skills, the four +> `/mt-*` commands, the enforce hook, and the MCP server-instruction block are the +> IMPLEMENTATIONS of the reflexes stated here. This file is the "why and when"; the +> skills are the "which tool". It is meant to ship with the plugin as its guidance +> doc, and to be adapted into an installable directive (see +> `INSTALLABLE-DIRECTIVE.md`) so the reflexes reach the agent's context. + +## The one reflex + +Inside a repository Memtrace has indexed, route CODE work through the graph BEFORE +raw text tools: + +- code discovery (locate a symbol, find callers, map a subsystem) goes to + `find_code` / `find_symbol` / `get_symbol_context`, not `grep` / `rg` / `git grep`; +- blast radius before an edit goes to `get_impact`, not a manual reference hunt; +- change history and "what moved when" go to `get_evolution` / `get_timeline` / + `get_changes_since`, not `git log` / `git diff`; +- decision rationale (why does this exist, was this banned) goes to Cortex decision + memory, not a guess from the diff. + +A 0 result from a graph tool means broaden the query or reindex, NOT fall back to +grep. Filename globbing (`find`, a name glob), config / data / docs, and paths +outside any indexed repo are UNAFFECTED: the reflex is about code content, not text +filtering. + +## The family map (what fires when) + +The plugin's skills group into intent families. Reach for the family, not the tool +name; a router skill picks the leaf. + +| Family | When it fires | Entry | +|---|---|---| +| Discovery and structure | locate code, traverse a symbol's edges, map a repo, run graph algorithms, see the API surface | `memtrace-first` (router) then search / relationships / codebase-exploration / graph / api-topology | +| Pre-edit safety and impact | what breaks if I change this; a gate before editing one symbol; a risk-rated plan for a multi-symbol change | impact -> preflight -> change-impact-analysis (a ladder by scope) | +| History and temporal | what changed over a range, one episode's diff, historical coupling, root-cause a failure, catch up at session start | evolution / episode-replay / cochange / incident-investigation / session-continuity / daily | +| Decision memory | why does this exist, was this decided or banned, a symbol's lineage, whether a decision held, where it was implemented | `memtrace-decision-memory` (router over recall_decision / why_is_this_here / governing_contracts / verify_intent / get_arc) | +| Docs | how does Memtrace work, install or config, what a tool does, read a docs page | `memtrace-docs` (router over search_docs / ask_docs / read_doc) | +| Fleet coordination | more than one agent shares a repo and branch: declare intent, record edits, resolve a conflict | `memtrace-fleet` (concept) then the publish-intent / record-episode / resolve leaves | +| Code health and quality | dead code and complexity hotspots, a refactor plan, a GitHub PR review, empirical style norms | quality / refactoring-guide / code-review / style-fingerprint | +| Indexing and lifecycle | index a repo once, or watch it for live re-index | index / continuous-memory | + +## The load-bearing reflexes + +1. Graph before grep, in an indexed repo, for code content. +2. Recall before you remove: before deleting or refactoring existing code you did not + just write, query decision memory (`recall_decision` / `why_is_this_here` / + `governing_contracts`) so a ban or contract is not silently broken. +3. Blast radius before you edit: `get_impact` on a single symbol, a bundled preflight + on one symbol, a risk-rated plan for a multi-symbol change. +4. Catch up from anchors, not from `git log`: session-continuity and the daily + briefing diff the graph at save granularity across sessions. +5. Fleet first on a shared branch: when more than one agent works the same repo and + branch, declare a typed intent before editing. + +## The layers that carry this + +- SKILLS: per-intent routing (the family map above), one skill per shape. +- COMMANDS: `/mt-onboard`, `/mt-preflight`, `/mt-recall`, `/mt-impact` as one-keystroke + entries into the most common workflows. +- HOOK: a deterministic PreToolUse gate redirects raw recursive code search to the + graph inside an indexed repo, and fails open on anything ambiguous (plain grep, a + filename glob, a path outside any indexed repo, the daemon down, or the opt-out + flag). +- MCP server-instructions: the always-injected form of reflex 1, leading with the + routing doctrine rather than a version banner (see `SERVER-INSTRUCTIONS.md`). +- INSTALLABLE directive: the same reflexes as a block a user adds to their `CLAUDE.md` + or a rules file after installing the plugin, so the doctrine is present in context + even where the hook is off or absent (see `INSTALLABLE-DIRECTIVE.md`). + +Opt-out is always available: `MEMTRACE_ENFORCE=off` disables the hook; the reflexes +stay advisory. diff --git a/plugins/memtrace-skills/commands/mt-impact.md b/plugins/memtrace-skills/commands/mt-impact.md new file mode 100644 index 0000000..fd75f13 --- /dev/null +++ b/plugins/memtrace-skills/commands/mt-impact.md @@ -0,0 +1,50 @@ +--- +description: Compute the blast radius of a planned change to a symbol via the graph +argument-hint: " [repo_id]" +allowed-tools: ["ToolSearch", "mcp__memtrace__list_indexed_repositories", "mcp__memtrace__find_symbol", "mcp__memtrace__get_impact", "mcp__memtrace__get_symbol_context"] +--- + +# mt-impact: blast radius for a planned change + +Compute what a change to one symbol will touch, from the AST graph, in three calls. Do +NOT hand-grep references: a raw search returns line hits with no caller graph or +cross-repo edge, so it undercounts the true blast radius. + +## Parameters + +`$ARGUMENTS` is ` [repo_id]`. The first token is the target symbol name +(required); the optional second token is a `repo_id`. + +## Behavior + +0. Preload the deferred tool schemas: + ``` + ToolSearch(query="select:mcp__memtrace__find_symbol,mcp__memtrace__get_impact,mcp__memtrace__get_symbol_context,mcp__memtrace__list_indexed_repositories") + ``` +1. Parse inputs. If the first token is missing, ask for the symbol and stop. Resolve + `repo_id` from the second token if present, else via `list_indexed_repositories` + matched against the current working directory. Flag a `path: null` stub: impact from + a stale index undercounts. +2. Run, in order: + - `find_symbol(name=, repo_id=)` - pin the exact symbol id. If + ambiguous, list matches and ask which one; do not guess. + - `get_impact` - the transitive impact set: direct plus indirect callers (who breaks + if the contract changes) and downstream dependents. Note cross-repo HTTP edges if + present. + - `get_symbol_context` - the symbol's role: its community, the processes it + participates in, and its immediate neighbors. This turns a raw count into a risk + story. +3. Report: the direct caller count, the total transitive impact size, the highest-risk + callers by name, any cross-repo edge, and the community or processes the symbol sits + in. Close with a one-line risk read (LOW / MEDIUM / HIGH) and, if HIGH, the callers to + update in the same change. + +## Example + +`/mt-impact parseConfig` computes blast radius for `parseConfig` in the cwd-resolved +repo. For a recorded-rationale check on top of the raw radius, chain into +`/mt-preflight` or `/mt-recall`. + +## Restrictions + +Read-only impact query: performs no edits and never touches the live index. diff --git a/plugins/memtrace-skills/commands/mt-onboard.md b/plugins/memtrace-skills/commands/mt-onboard.md new file mode 100644 index 0000000..529fcea --- /dev/null +++ b/plugins/memtrace-skills/commands/mt-onboard.md @@ -0,0 +1,50 @@ +--- +description: Map an indexed repo via the Memtrace graph (communities, symbols, processes) +argument-hint: "[repo_id]" +allowed-tools: ["ToolSearch", "mcp__memtrace__list_indexed_repositories", "mcp__memtrace__list_communities", "mcp__memtrace__find_central_symbols", "mcp__memtrace__list_processes", "mcp__memtrace__get_service_diagram"] +--- + +# mt-onboard: structured repo overview from the graph + +Build a structured architecture overview of an indexed repo from the Memtrace knowledge +graph. Do NOT reach for `Glob` / `find` / `tree` / `rg` to infer architecture: the graph +already carries communities, centrality, and execution flows in one pass. + +## Parameters + +`$ARGUMENTS` is an optional `repo_id`. When omitted, the command resolves the repo whose +indexed path matches the current working directory. + +## Behavior + +0. Preload the deferred tool schemas (the `mcp__memtrace__*` tools fail with + `InputValidationError` until their schema loads): + ``` + ToolSearch(query="select:mcp__memtrace__list_indexed_repositories,mcp__memtrace__list_communities,mcp__memtrace__find_central_symbols,mcp__memtrace__list_processes,mcp__memtrace__get_service_diagram") + ``` +1. Call `list_indexed_repositories`. + - If `$ARGUMENTS` is non-empty, use it verbatim as `repo_id`. + - Otherwise pick the entry whose `path` matches the current working directory. If the + match has `path: null` or `last_indexed_at: null`, the index is a stale stub: report + that and stop, since every downstream answer would come from stale data. + - If nothing matches the cwd and `$ARGUMENTS` is empty, list the candidate `repo_id` + values and ask which one, then stop. +2. Run, in order, each scoped to the resolved `repo_id`: + - `list_communities` - the main modules or subsystems. + - `find_central_symbols` - the highest-centrality symbols (read these first). + - `list_processes` - the main execution flows. + - `get_service_diagram` - how services or repos connect (cross-repo HTTP edges). +3. Synthesize a compact overview: scale (node/edge count from step 1's repo entry), the + top 3 to 5 communities, the 5 most central symbols, the main processes, and any + cross-service edges. Name the entry points a new contributor should read first. Do not + dump raw tool JSON. + +## Example + +`/mt-onboard` run inside an indexed repo's working directory returns a one-page +architecture map with no further input. `/mt-onboard backend-api` targets a specific +indexed `repo_id` instead of resolving from cwd. + +## Restrictions + +Read-only discovery command: issues no writes and never resets the index. diff --git a/plugins/memtrace-skills/commands/mt-preflight.md b/plugins/memtrace-skills/commands/mt-preflight.md new file mode 100644 index 0000000..f96a1e0 --- /dev/null +++ b/plugins/memtrace-skills/commands/mt-preflight.md @@ -0,0 +1,53 @@ +--- +description: Pre-edit safety check on a symbol (blast radius, co-change, decisions, churn) +argument-hint: " [repo_id]" +allowed-tools: ["ToolSearch", "mcp__memtrace__list_indexed_repositories", "mcp__memtrace__find_symbol", "mcp__memtrace__get_impact", "mcp__memtrace__get_cochange_context", "mcp__memtrace__recall_decision", "mcp__memtrace__why_is_this_here", "mcp__memtrace__get_timeline"] +--- + +# mt-preflight: pre-edit safety on a symbol + +Before modifying an existing symbol, gather blast radius, hidden coupling, recorded +rationale, and stability in one pass. Do NOT start editing a non-trivial existing +function without this: the graph knows the dependency edges, the git-episode co-change +partners, and any recorded ban that a diff read alone would miss. + +## Parameters + +`$ARGUMENTS` is ` [repo_id]`. The first token is the target symbol name +(required); the optional second token is a `repo_id`. + +## Behavior + +0. Preload the deferred tool schemas: + ``` + ToolSearch(query="select:mcp__memtrace__find_symbol,mcp__memtrace__get_impact,mcp__memtrace__get_cochange_context,mcp__memtrace__recall_decision,mcp__memtrace__why_is_this_here,mcp__memtrace__get_timeline,mcp__memtrace__list_indexed_repositories") + ``` +1. Parse inputs. If the first token is missing, ask for the symbol name and stop. + Resolve `repo_id` from the second token if present, else via + `list_indexed_repositories` matched against the current working directory. A + `path: null` / `last_indexed_at: null` match is a stale stub: flag it, results may be + incomplete. +2. Run, in order: + - `find_symbol(name=, repo_id=)` - confirm the exact symbol id. If + several match, list them and ask which one. + - `get_impact` - transitive blast radius (upstream callers plus downstream + dependents). Highest-value probe; do not skip it. + - `get_cochange_context` - files or symbols that historically move with this one (the + hidden edits a diff would forget). + - `recall_decision(query= + intent)` then `why_is_this_here(symbol=)` + - any recorded decision, ban, or convention governing this code. A hit here can veto + the planned edit. + - `get_timeline` - recent modification history (recently churned code is less stable). +3. Emit a short risk verdict: LOW / MEDIUM / HIGH, with the caller count, the co-change + partners to touch together, any governing decision or ban (quoted), and whether the + symbol is stable or hot. If a ban or contract forbids the change, say so and stop + before editing. + +## Example + +`/mt-preflight parseConfig` checks the symbol `parseConfig` in the cwd-resolved repo. +`/mt-preflight parseConfig backend-api` scopes the same check to `repo_id: backend-api`. + +## Restrictions + +Read-only pre-flight: performs no edits and never touches the live index. diff --git a/plugins/memtrace-skills/commands/mt-recall.md b/plugins/memtrace-skills/commands/mt-recall.md new file mode 100644 index 0000000..b8aaf13 --- /dev/null +++ b/plugins/memtrace-skills/commands/mt-recall.md @@ -0,0 +1,53 @@ +--- +description: Recall decisions, bans, and conventions from Cortex memory before a refactor +argument-hint: " [repo_id]" +allowed-tools: ["ToolSearch", "mcp__memtrace__list_indexed_repositories", "mcp__memtrace__recall_decision", "mcp__memtrace__why_is_this_here", "mcp__memtrace__governing_contracts", "mcp__memtrace__verify_intent"] +--- + +# mt-recall: decision-memory recall before delete / refactor / library re-pick + +Do NOT infer why code exists from the diff or `git log`, and do NOT assume unfamiliar +code is safe to remove. Cortex decision memory records the bans, conventions, and +contracts that a text search cannot see; recall them first. + +## Parameters + +`$ARGUMENTS` is ` [repo_id]`. The query may be free text ("did we ban X", "why do +we use Y") or a symbol name. The optional trailing token, if it names an indexed repo, +is used as `repo_id`. + +## Behavior + +0. Preload the deferred tool schemas: + ``` + ToolSearch(query="select:mcp__memtrace__recall_decision,mcp__memtrace__why_is_this_here,mcp__memtrace__governing_contracts,mcp__memtrace__verify_intent,mcp__memtrace__list_indexed_repositories") + ``` +1. Parse inputs. If `$ARGUMENTS` is empty, ask what decision or symbol to check and stop. + Resolve `repo_id` from a trailing token that names an indexed repo, else via + `list_indexed_repositories` matched against the current working directory. +2. Recall, widening then narrowing: + - `recall_decision(query=, repo_id=)` - ranked decisions, bans, or + conventions matching the intent. Read the top hits; a ban here vetoes the planned + action. + - If the target is a specific symbol: `why_is_this_here(symbol=)` - the decision + lineage that put it there. + - `governing_contracts(symbol=)` - the invariants or contracts that bind the + symbol (what must not be broken). + - If a decision looks relevant, `verify_intent(decision_id=)` - did that decision + actually hold, or was it violated (Held / ViolatedAt / CannotProve)? Requires a + `decision_id` from a prior step. +3. State plainly whether there is a recorded decision, ban, or contract touching this. + Quote it with its id. Give the go / no-go: proceed, proceed-with-constraint (name the + contract), or stop (a ban applies). Zero recall hits is not proof of absence: if the + index is a stale stub (`last_indexed_at: null`), say the recall is unreliable rather + than clearing the action. + +## Example + +`/mt-recall "did we ban global mutable state"` returns any matching decision with a +go/no-go verdict. `/mt-recall parseConfig backend-api` recalls decision lineage for that +symbol in `repo_id: backend-api`. + +## Restrictions + +Read-only recall: makes no edits and never resets the index. diff --git a/plugins/memtrace-skills/hooks/hooks.json.example b/plugins/memtrace-skills/hooks/hooks.json.example new file mode 100644 index 0000000..e3b3e4f --- /dev/null +++ b/plugins/memtrace-skills/hooks/hooks.json.example @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Grep|Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/memtrace-enforce-pretooluse.sh", + "timeout": 5000 + } + ] + } + ], + "Stop": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/memtrace-enforce-stop.sh", + "timeout": 5000 + } + ] + } + ] + } +} diff --git a/plugins/memtrace-skills/hooks/memtrace-enforce-pretooluse.sh b/plugins/memtrace-skills/hooks/memtrace-enforce-pretooluse.sh new file mode 100755 index 0000000..90a910b --- /dev/null +++ b/plugins/memtrace-skills/hooks/memtrace-enforce-pretooluse.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# +# memtrace-enforce-pretooluse.sh: deterministic PreToolUse gate for Memtrace. +# +# Wired as a PreToolUse hook (matcher "Grep|Bash") so that, inside a repository +# Memtrace has indexed, raw recursive code search is redirected to the graph +# tools that answer the same question with caller edges, cross-repo links, and +# decision context attached: +# +# mcp__memtrace__find_code find code by meaning / content +# mcp__memtrace__find_symbol locate a definition by name +# mcp__memtrace__get_symbol_context / get_impact role and blast radius +# +# The bundled skills already ADVISE this redirect (a banner, a skill +# description); they cannot ENFORCE it. This hook is the deterministic lever: +# it denies the search and names the tool to use instead, so the redirect +# happens even when the model reaches for grep out of habit. +# +# --------------------------------------------------------------------------- +# DESIGN CONTRACT +# +# Fail-open by construction. Any ambiguity resolves to ALLOW. The hook NEVER +# blocks: +# - Glob or filename discovery (find/fd/bfs): those locate files by name. +# - plain grep or a real stdin pipe (cat x | grep pat): stream filtering. +# - config, data, docs, logs, lockfiles: non-source targets. +# - any path outside an indexed repository. +# - anything, when the Memtrace backend is not running. +# Rationale: a missed search is a cheap regret; a blocked legitimate command +# is an expensive one. When in doubt, allow. +# +# Opt-out: +# - global: export MEMTRACE_ENFORCE=off +# - per-repo: remove/comment its line in the scope list. +# +# Scope list (the set of indexed repository roots), resolved in order: +# 1. $MEMTRACE_ENFORCE_REPOS (explicit override) +# 2. $HOME/.memtrace/enforce-repos (seeded by `memtrace install`, +# refreshed on index; one root +# path per line, # comments +# allowed) +# No list present -> the hook is inert (allow). This keeps a fresh install +# silent until a repository has actually been indexed. +# +# Liveness: the memcore-server gRPC backend (it serves find_code). The HTTP +# UI port is often down while the backend is fully live, so the process is +# the reliable signal. Set MEMTRACE_HEALTH_URL to force an HTTP probe +# instead. Test override: MEMTRACE_ENFORCE_HEALTH=ok|fail bypasses the +# liveness probe entirely (used by the fixture-driven negative-case tests; +# never wire this in a live install). +# --------------------------------------------------------------------------- +set -uo pipefail + +allow(){ exit 0; } +deny(){ + python3 -c 'import json,sys; print(json.dumps({"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":sys.argv[1]}}))' "$1" + exit 0 +} + +# 0. Global opt-out and empty input. +[[ "${MEMTRACE_ENFORCE:-on}" == "off" ]] && allow +input="$(cat 2>/dev/null || true)"; [[ -n "$input" ]] || allow + +# 1. Parse the hook payload into: tool name, and two tool-specific fields. +vals="$(printf '%s' "$input" | python3 -c ' +import json,sys +try: o=json.load(sys.stdin) +except Exception: o={} +ti=o.get("tool_input",{}) or {} +tn=o.get("tool_name",""); cwd=o.get("cwd","") +print(tn) +if tn=="Grep": + print(ti.get("pattern","") or ""); print(ti.get("path","") or cwd) +elif tn=="Bash": + print(ti.get("command","") or ""); print(cwd) +else: + print(""); print("") +' 2>/dev/null || true)" +TOOL="$(sed -n 1p <<<"$vals")" +A1="$(sed -n 2p <<<"$vals")" +A2="$(sed -n 3p <<<"$vals")" + +# 2. Scope: is a path under one of the indexed repository roots? +LIST="${MEMTRACE_ENFORCE_REPOS:-$HOME/.memtrace/enforce-repos}" +in_scope(){ + local p="$1" line root; [[ -n "$p" && -f "$LIST" ]] || return 1 + while IFS= read -r line || [[ -n "$line" ]]; do + root="${line%%#*}" + root="${root#"${root%%[![:space:]]*}"}"; root="${root%"${root##*[![:space:]]}"}"; root="${root%/}" + [[ -z "$root" ]] && continue + case "$p/" in "$root"/*) return 0 ;; esac + done < "$LIST" + return 1 +} + +# 3. Liveness (last, only for a real candidate). Fail-open when unreachable. +daemon_ok(){ + local h="${MEMTRACE_ENFORCE_HEALTH:-}" # test override: ok|fail + [[ -n "$h" ]] && { [[ "$h" == ok ]]; return; } + if [[ -n "${MEMTRACE_HEALTH_URL:-}" ]]; then + curl -sf --max-time 1 "$MEMTRACE_HEALTH_URL" >/dev/null 2>&1 + else + pgrep -f memcore-server >/dev/null 2>&1 + fi +} + +case "$TOOL" in + Grep) + # npm / Windows builds still expose the Grep tool. Redirect content search + # on an explicit SOURCE FILE; leave config/data/docs targets alone. A no-path + # or directory target is ambiguous and allowed (fail-open, below). Redirecting + # an explicit in-scope source FILE is INTENTIONAL: it is a code-content lookup + # that find_code/find_symbol answers with callers and context, distinct from + # Bash `grep foo file` stream filtering (always allowed). Opt-out per repo or + # MEMTRACE_ENFORCE=off if a literal single-file grep is wanted. + PATTERN="$A1"; TPATH="$A2" + shopt -s nocasematch + case "$TPATH" in + *.env|*.env.*|*package.json|*.json|*.yaml|*.yml|*.toml|*.ini|*.cfg|*.conf|*.lock|*.md|*.txt|*.csv|*.tsv|*.xml|*readme*|*license*) + shopt -u nocasematch; allow ;; + esac + shopt -u nocasematch + # A target whose basename has NO file extension (a directory, or an omitted + # Grep path that defaulted to the repo root) is ambiguous between code and + # docs/config -> allow (design-contract fail-open: when in doubt, allow). + # Only an explicit FILE path proceeds to the source-vs-docs redirect. + case "${TPATH##*/}" in + *.*) : ;; + *) allow ;; + esac + [[ -n "$TPATH" && -n "$PATTERN" ]] || allow + in_scope "$TPATH" || allow + daemon_ok || allow + deny "Memtrace has indexed this repository. Prefer mcp__memtrace__find_code(query=\"$PATTERN\") or mcp__memtrace__find_symbol over Grep: same match, plus callers, cross-repo edges, and decision context. Config, data, docs, and non-indexed paths are unaffected. Opt-out: MEMTRACE_ENFORCE=off." + ;; + + Bash) + # Native builds (Claude Code 2.1.116+) run ugrep/bfs THROUGH Bash. Classify + # the command: 1 = a standalone recursive code search, 0 = anything else. + # + # Recognized as code search (unless a real stdin pipe feeds it, or it + # targets a data/doc path): rg / ripgrep / ug / ugrep / ag / ack / sift / + # pt; grep / egrep / fgrep carrying -r/-R/--recursive/--include/ + # --exclude-dir; git grep. Best-effort de-obfuscation strips leading VAR= + # assignments and the env / command / builtin / exec / nohup / time / + # xargs / eval wrappers, a backslash-escape, and an absolute path, and it + # also scans commands inside $(...) and backtick substitutions. This + # widening is intentionally lossy: anything it cannot resolve falls + # through to ALLOW. + CMD="$A1"; CWD="$A2"; [[ -n "$CMD" ]] || allow + cls="$(MTE_CMD="$CMD" python3 -c ' +import os,re,shlex +cmd=os.environ.get("MTE_CMD","") +SEARCH=("rg","ripgrep","ug","ugrep","ag","ack","sift","pt") +PATHDATA=re.compile(r"\.(log|json|ya?ml|toml|ini|cfg|conf|lock|md|txt|csv|tsv|xml|html?)$|(?:^|/)(?:docs?|node_modules|dist|build|target)(?:/|$)|(?:^|/)(?i:readme|license|changelog|contributing)(?:\.[a-z0-9]+)?$|/var/log|/\.git(?:/|$)") +def unwrap(toks): + ci=0; g=0 + while ci2: return None + seg=seg.strip() + if not seg: return None + try: toks=shlex.split(seg) + except Exception: return None + toks=unwrap(toks) + if not toks: return None + base=toks[0].lstrip("\\").rsplit("/",1)[-1]; rest=toks[1:] + if base=="xargs": + j=0 + while j allow; a code path, or a + # repo-wide search with no path, -> redirect. The pattern is never counted as + # a path, so an unquoted pattern like `rg docs src/` still redirects. + if paths and all(PATHDATA.search(pp) for pp in paths): print("0") + else: print("1") +' 2>/dev/null || echo 0)" + [[ "$cls" == "1" ]] || allow + in_scope "$CWD" || allow + daemon_ok || allow + deny "Memtrace has indexed this repository. Prefer mcp__memtrace__find_code / mcp__memtrace__find_symbol over recursive code search (rg / ugrep / grep -r / git grep): same match, plus callers, cross-repo edges, and decision context. Plain or piped grep, logs, config, and non-indexed paths are unaffected. Opt-out: MEMTRACE_ENFORCE=off." + ;; + + *) allow ;; +esac +allow diff --git a/plugins/memtrace-skills/hooks/memtrace-enforce-stop.sh b/plugins/memtrace-skills/hooks/memtrace-enforce-stop.sh new file mode 100755 index 0000000..3d622e5 --- /dev/null +++ b/plugins/memtrace-skills/hooks/memtrace-enforce-stop.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# memtrace-enforce-stop.sh: Stop-hook backstop for Memtrace. +# +# The PreToolUse gate (memtrace-enforce-pretooluse.sh) denies raw code search +# in an indexed repo; this hook catches what a deny cannot: turns where the +# agent never attempted a search at all, and still finished without using the +# graph. It blocks the agent stop and forces ONE continuation when the last +# turn, inside an indexed repo, did any of: +# +# (A) code discovery (source-file Read, or Grep content search) with NO +# memtrace MCP call -> pairs with the PreToolUse deny: "did not grep" +# becomes "used memtrace". Glob is filename-by-name discovery (out of +# scope, like find/bfs) and never counts as code discovery here. +# (B) deleted/refactored an existing symbol (an Edit/MultiEdit that shrinks +# a definition) with NO decision-memory call (recall_decision / +# why_is_this_here / get_symbol_context / get_impact / +# governing_contracts) -> nudge to check WHY the code exists before +# removing it. +# (C) leaned on git log / git diff for catch-up with NO +# temporal-memory call (get_evolution / get_changes_since / +# get_timeline) -> nudge to the graph change memory, which is +# symbol-level and cross-repo where git is not. +# +# Calibrated to never loop or false-positive: +# - anti-loop: stop_hook_active=true -> allow (one forced continuation only, +# so each nudge costs at most one extra turn even if detection is +# generous) +# - scope: only inside an indexed repo (the enforce list); else allow +# - fail-open: daemon down -> allow; opt-out MEMTRACE_ENFORCE=off; empty +# input -> allow +# - a memfleet__ call satisfies the same graph as memtrace__ (both expose +# find_code) +# +# The parsed-language set for (A) tracks what Memtrace indexes; extend EXT as +# the indexer gains grammars. Test override: MEMTRACE_ENFORCE_HEALTH=ok|fail +# bypasses the liveness probe (used by the fixture-driven negative-case +# tests; never wire this in a live install). +set -uo pipefail +allow(){ exit 0; } +block(){ python3 -c 'import json,sys;print(json.dumps({"decision":"block","reason":sys.argv[1]}))' "$1"; exit 0; } + +[[ "${MEMTRACE_ENFORCE:-on}" == "off" ]] && allow +input="$(cat 2>/dev/null || true)"; [[ -n "$input" ]] || allow + +vals="$(printf '%s' "$input" | python3 -c ' +import json,sys +try: o=json.load(sys.stdin) +except Exception: o={} +print("true" if o.get("stop_hook_active") else "false") +print(o.get("transcript_path","") or "") +print(o.get("cwd","") or "") +' 2>/dev/null || true)" +ACTIVE="$(sed -n 1p <<<"$vals")" +TRANSCRIPT="$(sed -n 2p <<<"$vals")" +CWD="$(sed -n 3p <<<"$vals")" + +[[ "$ACTIVE" == "true" ]] && allow # anti-loop +[[ -n "$TRANSCRIPT" && -f "$TRANSCRIPT" ]] || allow + +# daemon fail-open: liveness = memcore-server gRPC backend alive (serves +# find_code). The HTTP UI is often down even when memtrace is live; the +# backend process is the reliable signal. Set MEMTRACE_HEALTH_URL to force +# the legacy HTTP probe. +health="${MEMTRACE_ENFORCE_HEALTH:-}" +if [[ -z "$health" ]]; then + if [[ -n "${MEMTRACE_HEALTH_URL:-}" ]]; then + curl -sf --max-time 1 "$MEMTRACE_HEALTH_URL" >/dev/null 2>&1 && health=ok || health=fail + else + pgrep -f memcore-server >/dev/null 2>&1 && health=ok || health=fail + fi +fi +[[ "$health" == "ok" ]] || allow + +# scope: cwd under an indexed repo root +LIST="${MEMTRACE_ENFORCE_REPOS:-$HOME/.memtrace/enforce-repos}" +[[ -n "$CWD" && -f "$LIST" ]] || allow +in=0 +while IFS= read -r line || [[ -n "$line" ]]; do + root="${line%%#*}"; root="${root#"${root%%[![:space:]]*}"}"; root="${root%"${root##*[![:space:]]}"}"; root="${root%/}" + [[ -z "$root" ]] && continue + case "$CWD/" in "$root"/*) in=1; break ;; esac +done < "$LIST" +[[ "$in" -eq 1 ]] || allow + +# analyze the last turn; emit one of: allow | discover | decision | evolution +verdict="$(MTE_T="$TRANSCRIPT" python3 -c ' +import json,os,re +try: lines=open(os.environ["MTE_T"]).read().splitlines() +except Exception: print("allow"); raise SystemExit +EXT=(".rs",".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".cjs",".py",".go", + ".java",".c",".cc",".cpp",".h",".hpp",".rb",".php",".cs",".swift",".kt", + ".scala",".sh",".lua",".ml",".vue",".svelte",".dart",".ex",".exs") +DEF=re.compile(r"\b(fn|def|defp|function|func|class|impl|trait|interface|struct|enum|module|defmodule|type|const|let|var|public|private|protected)\b") +# last user text turn = turn boundary +start=0 +for i,l in enumerate(lines): + try: o=json.loads(l) + except Exception: continue + if o.get("type")=="user": + c=o.get("message",{}).get("content",[]) + if isinstance(c,str) or (isinstance(c,list) and any(isinstance(b,dict) and b.get("type")=="text" for b in c)): + start=i +mem=False; decmem=False; temporal=False +cd=False; refactor=False; gitcatch=False +def src(fp): return bool(fp) and fp.endswith(EXT) +for l in lines[start:]: + try: o=json.loads(l) + except Exception: continue + c=o.get("message",{}).get("content",[]) + if not isinstance(c,list): continue + for b in c: + if not isinstance(b,dict) or b.get("type")!="tool_use": continue + n=b.get("name",""); inp=(b.get("input",{}) or {}) + if n.startswith("mcp__memtrace__") or n.startswith("mcp__memfleet__"): + mem=True + leaf=n.split("__")[-1] + if leaf in ("recall_decision","why_is_this_here","get_symbol_context","get_impact","governing_contracts","verify_intent"): decmem=True + if leaf in ("get_evolution","get_changes_since","get_timeline","replay_history","get_arc","get_cochange_context","get_episode_replay"): temporal=True + elif n=="Grep": cd=True # Grep = content search (in scope); Glob = filename-by-name (out of scope, never flagged) + elif n=="Read": + if src(inp.get("file_path","") or ""): cd=True + elif n in ("Edit","MultiEdit"): + if not src(inp.get("file_path","") or ""): continue + edits=inp.get("edits") if n=="MultiEdit" else [inp] + if not isinstance(edits,list): edits=[inp] + for e in edits: + if not isinstance(e,dict): continue + old=e.get("old_string","") or ""; new=e.get("new_string","") or "" + # a shrink over a definition line = deletion/refactor of a symbol + if DEF.search(old) and (len(new) < len(old)) and (new.strip()=="" or len(new) < 0.7*len(old)): + refactor=True + elif n=="Bash": + cmd=inp.get("command","") or "" + if re.search(r"\bgit\s+log\b",cmd) or re.search(r"\bgit\s+diff\b[^|]*(\b[0-9a-f]{7,40}\b|\.\.|HEAD[~^]|origin/)",cmd): + gitcatch=True +if cd and not mem: print("discover") +elif refactor and not decmem: print("decision") +elif gitcatch and not temporal: print("evolution") +else: print("allow") +' 2>/dev/null || echo allow)" + +case "$verdict" in + discover) + block "Memtrace is active in this indexed repo and this turn did code discovery (source-file reads or search) without it. Before finishing, use mcp__memtrace__find_code / mcp__memtrace__find_symbol for the code-discovery part instead of raw reads. Opt-out: MEMTRACE_ENFORCE=off." + ;; + decision) + block "Memtrace is active and this turn deleted or refactored an existing symbol without checking why it exists. Before finishing, run mcp__memtrace__recall_decision / mcp__memtrace__why_is_this_here (or get_symbol_context / get_impact) to confirm no decision, ban, or contract governs it. Opt-out: MEMTRACE_ENFORCE=off." + ;; + evolution) + block "Memtrace is active and this turn used git log / git diff to catch up on history. Before finishing, prefer mcp__memtrace__get_evolution / mcp__memtrace__get_changes_since: symbol-level, cross-repo change memory git cannot reconstruct. Opt-out: MEMTRACE_ENFORCE=off." + ;; + *) allow ;; +esac +allow