diff --git a/README.md b/README.md index 38ab2be6..d4fcf849 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,18 @@ curl -fsSL https://raw.githubusercontent.com/XTraceAI/agent-plugins/main/install This installs MemHub into each of Claude Code, Codex and Cursor it finds on `PATH` (`sh -s -- --agents claude,codex` for a subset), disables any other -enabled MemHub copy in Claude Code so sessions are not captured twice, installs -the Codex hooks bridge, and signs the machine in once — every agent's capture -hooks read the same credential. It is safe to re-run. It cannot do the steps +enabled MemHub copy in Claude Code so sessions are not captured twice, turns on +Claude Code's marketplace auto-update and its fork agent (`--no-auto-update` / +`--no-fork-agent` leave either as it is), installs the Codex hooks bridge, and +signs the machine in once — every agent's capture hooks read the same +credential. On a first install it asks two things when a terminal is attached: +where MemHub is active (only repositories you add later with +`/memhub:repos add .`, every repository, or a list now) and whether to show the +companion animal (off by default; a one-line status strip shows instead). +`--selected-repos`, `--all-repos`, `--repos org/a,org/b`, `--companion` and +`--no-companion` answer from the command line (after `sh -s --`); unattended, it +picks selected repositories (none yet) and no animal. It is safe to re-run. It +cannot do the steps only the host UI can: restarting, trusting the Codex hooks in `/hooks`, and selecting **Add** in Cursor; it prints those at the end. The per-host steps below are the same install done by hand. diff --git a/install.sh b/install.sh index 2e760dc2..e335219d 100755 --- a/install.sh +++ b/install.sh @@ -14,9 +14,25 @@ # --no-login skip the sign-in step # --no-auto-update leave Claude Code's marketplace auto-update as it is # (default: turned on, so the plugin follows each release) -# --no-fork-agent leave Claude Code's fork agent setting as it is (default: +# --no-fork-agent leave Claude Code's fork agent settings as they are (default: # CLAUDE_CODE_FORK_SUBAGENT=1 in settings.json, so rule -# drafts run in the desktop app too, not only the terminal) +# drafts run in the desktop app too, not only the terminal, +# and Agent(fork) in permissions.allow, so auto mode does +# not review, and often deny, each draft's launch) +# --repos LIST make MemHub active only in these repositories (org/name, as +# the origin remote spells them, comma-separated); writes +# ~/.config/memhub-plugin/repos.json. Everywhere else the +# plugin is dormant: no team rules, no capture, no animal +# --selected-repos make MemHub active in no repository yet; add each one later +# with /memhub:repos add . inside it +# --all-repos make MemHub active in every repository (removes repos.json) +# --companion show the companion animal above the Claude Code prompt +# --no-companion keep the one-line MemHub status in the animal's place +# +# A FIRST install asks about those two settings — where MemHub is active, and +# the animal — when a terminal is attached; a flag answers its question in +# advance. Unattended, the quiet choice wins: selected repositories (none yet) +# and no animal. A re-run asks nothing and leaves the repository list alone. # # Safe to run again: every step checks what is already there. One sign-in # covers every agent on the machine, because the capture hooks of all three @@ -29,6 +45,9 @@ AGENTS="" LOGIN=1 AUTO_UPDATE=1 FORK_AGENT=1 +REPOS="" # --repos LIST +REPOS_MODE="" # "list" | "all" | "" (not asked for) +COMPANION="" # 1 | 0 | "" (not asked for) while [ $# -gt 0 ]; do case "$1" in @@ -38,11 +57,18 @@ while [ $# -gt 0 ]; do --no-login) LOGIN=0 ;; --no-auto-update) AUTO_UPDATE=0 ;; --no-fork-agent) FORK_AGENT=0 ;; - -h|--help) sed -n '2,25p' "$0" 2>/dev/null || true; exit 0 ;; + --repos) [ $# -ge 2 ] || { echo "--repos needs a comma-separated list of org/name" >&2; exit 2; }; REPOS="$2"; REPOS_MODE=list; shift ;; + --selected-repos) REPOS_MODE=selected ;; + --all-repos) REPOS_MODE=all ;; + --companion) COMPANION=1 ;; + --no-companion) COMPANION=0 ;; + -h|--help) awk 'NR > 1 && /^# Safe to run again/ { exit } NR > 1' "$0" 2>/dev/null || true; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac shift done +[ "$REPOS_MODE" = list ] && [ -z "$(printf '%s' "$REPOS" | tr -d ' ,')" ] \ + && { echo "--repos needs at least one org/name (or --selected-repos, or --all-repos)" >&2; exit 2; } say() { printf '%s\n' "$*"; } step() { printf '\n==> %s\n' "$*"; } @@ -80,6 +106,77 @@ fi command -v python3 >/dev/null 2>&1 \ || fail "python3 is required: MemHub's hooks run on it" +# The plugin's per-user state, where the repository list goes. Whether this is +# a FIRST install is decided here, before any step below creates the directory +# (the sign-in writes the access key there). +CONFIG_DIR="${MEMHUB_CONFIG_DIR:-$HOME/.config/memhub-plugin}" +REPOS_FILE="$CONFIG_DIR/repos.json" +FRESH=0; [ -e "$CONFIG_DIR" ] || FRESH=1 + +# Write the repository list: $1 is a comma-separated org/name list ("" for an +# empty list, which leaves MemHub active nowhere until a repository is added). +write_repos() { + python3 -c ' +import json, os, sys, tempfile +path, raw = sys.argv[1], sys.argv[2] +seen, repos = set(), [] +for r in raw.split(","): + r = r.strip().strip("/") + if r.endswith(".git"): + r = r[:-4] + if r and r.lower() not in seen: + seen.add(r.lower()); repos.append(r) +os.makedirs(os.path.dirname(path), exist_ok=True) +fd, tmp = tempfile.mkstemp(prefix="repos.json.memhub-", dir=os.path.dirname(path)) # 0600 +with os.fdopen(fd, "w") as f: + f.write(json.dumps({"version": 1, "mode": "allowlist", "repos": sorted(repos, key=str.lower)}, indent=2) + "\n") +os.replace(tmp, path) +print("MemHub is active only in: " + (", ".join(repos) if repos else "(no repository yet)")) +' "$REPOS_FILE" "$1" +} + +# ── Setup: what a first install asks ───────────────────────────────────────── +# Under `curl | sh` stdin is this script, so questions read /dev/tty. A flag +# answers its question in advance; unattended, the quiet choice wins: active +# in no repository until one is added, and no animal. +TTY=0; ( : < /dev/tty ) 2>/dev/null && TTY=1 +tty_say() { [ "$TTY" = 1 ] && printf '%s\n' "$*" > /dev/tty; return 0; } +# ask PROMPT DEFAULT: the person's answer in $ANS, spaces dropped; the default +# on an empty line, on end of input, or with no terminal to ask. +ask() { + ANS="$2" + [ "$TTY" = 1 ] || return 0 + printf '%s' "$1" > /dev/tty + read -r ANS < /dev/tty || ANS="$2" + ANS=$(printf '%s' "$ANS" | tr -d ' ') + [ -n "$ANS" ] || ANS="$2" +} +if [ "$FRESH" = 1 ] && { [ -z "$REPOS_MODE" ] || [ -z "$COMPANION" ]; }; then + step "Setup" + if [ -z "$REPOS_MODE" ]; then + tty_say "Where should MemHub be active? Everywhere else it stays dormant: no team" + tty_say "rules, no session capture, nothing above the prompt." + tty_say " 1) Only repositories I choose: add each later with /$PLUGIN:repos add ." + tty_say " 2) Every repository on this machine" + tty_say " 3) A list now, as the origin remote spells them (org/name, comma-separated)" + ask "Choice [1]: " 1 + case "$ANS" in + 1) REPOS_MODE=selected ;; + 2) REPOS_MODE=all ;; + 3) ask " Repositories (org/a,org/b): " "" + if [ -n "$(printf '%s' "$ANS" | tr -d ' ,')" ]; then REPOS="$ANS"; REPOS_MODE=list + else say "no repository named: MemHub is active in none yet"; REPOS_MODE=selected; fi ;; + *) say "'$ANS' is not one of the choices: MemHub is active in no repository yet"; REPOS_MODE=selected ;; + esac + fi + if [ -z "$COMPANION" ]; then + tty_say "Show the companion animal above the Claude Code prompt? Without it, a" + tty_say "one-line MemHub status stands in its place." + ask "Animal [y/N]: " N + case "$ANS" in y|Y|yes|Yes|YES) COMPANION=1 ;; *) COMPANION=0 ;; esac + fi +fi + wanted() { [ -z "$AGENTS" ] && return 0 case ",$AGENTS," in *",$1,"*) return 0 ;; esac @@ -156,7 +253,15 @@ print("1" if isinstance(entry, dict) and entry.get("autoUpdate") is True else "0 # Two commands, not `install --marketplace` (that flag needs Claude Code # 2.1.292+). Both are no-ops when already done. "$CLAUDE_BIN" plugin marketplace add "$CLAUDE_SRC" - "$CLAUDE_BIN" plugin install "$PLUGIN@$CLAUDE_MKT" --scope user + # --companion: the plugin's `companion` option (off by default, a one-line + # status in the animal's place). `--config` is honoured by a fresh install + # only; on a machine that has the plugin already, set it in /plugin. + if [ "${COMPANION:-0}" = 1 ]; then + "$CLAUDE_BIN" plugin install "$PLUGIN@$CLAUDE_MKT" --scope user --config companion=true \ + || "$CLAUDE_BIN" plugin install "$PLUGIN@$CLAUDE_MKT" --scope user + else + "$CLAUDE_BIN" plugin install "$PLUGIN@$CLAUDE_MKT" --scope user + fi # A re-run must reach the current release: neither command above refreshes # an already-added marketplace or an installed plugin. --scope user, or # Claude updates a project/local copy instead of the one installed here. @@ -216,12 +321,16 @@ else: # Every MemHub copy registers the same hooks and an MCP server named # `memhub`; two enabled means every session is captured twice and every rule # fires twice. Disable (not uninstall) the others, so this is reversible. + # `plugin list --json` prints one row per scope a plugin is installed in; + # disable each id once, or every row after the first warns that it failed. "$CLAUDE_BIN" plugin list --json | python3 -c ' import json, sys target = sys.argv[1] +seen = set() for p in json.load(sys.stdin): name = p["id"].split("@", 1)[0] - if name in ("memhub", "memhub-staging") and p["id"] != target and p.get("enabled"): + if name in ("memhub", "memhub-staging") and p["id"] != target and p.get("enabled") and p["id"] not in seen: + seen.add(p["id"]) print(p["id"]) ' "$PLUGIN@$CLAUDE_MKT" | while read -r dup; do if "$CLAUDE_BIN" plugin disable "$dup" >/dev/null 2>&1 \ @@ -248,8 +357,15 @@ for p in json.load(sys.stdin): # doing it here covers a desktop-only person from their first session. The # script backs settings.json up, keeps a key set off by hand (exit 1, says # so), and writes nothing when rule drafting is off. + # --allow-rule adds Agent(fork) to permissions.allow too. Auto mode otherwise + # reviews each launch, and denies a fair share, because the request comes + # from a hook's output. Only here: onboard runs inside a session, where the + # classifier refuses an agent writing its own allow rule. Probed first so an + # older released plugin, whose script lacks the flag, still gets the key. if [ "$FORK_AGENT" = 1 ] && [ -f "$PLUGIN_ROOT/scripts/setup_claude_fork.py" ]; then - rc=0; python3 "$PLUGIN_ROOT/scripts/setup_claude_fork.py" install --claude-home "$CLAUDE_HOME" || rc=$? + ALLOW_RULE="" + grep -q -- "--allow-rule" "$PLUGIN_ROOT/scripts/setup_claude_fork.py" && ALLOW_RULE="--allow-rule" + rc=0; python3 "$PLUGIN_ROOT/scripts/setup_claude_fork.py" install --claude-home "$CLAUDE_HOME" $ALLOW_RULE || rc=$? [ "$rc" = 2 ] && say "warning: could not set Claude Code's fork agent; add \"CLAUDE_CODE_FORK_SUBAGENT\": \"1\" under env in $CLAUDE_HOME/settings.json" >&2 fi LOGIN_HOST=${LOGIN_HOST:-claude-code} @@ -258,7 +374,28 @@ fi if [ -n "$CODEX_BIN" ]; then step "Codex" - "$CODEX_BIN" plugin marketplace add "$CODEX_SRC" + # Codex keys a marketplace snapshot by its exact source (URL spelling, ref, + # sparse paths). A snapshot of this same name recorded from any other source + # — an older Codex, a URL typed by hand, a staging install — makes `add` + # refuse ("already added from a different source"). `remove` drops only that + # snapshot and its config entry; the plugin's own entry survives, and the + # `plugin add` below re-installs it from the fresh snapshot. + out=""; rc=0; out=$("$CODEX_BIN" plugin marketplace add "$CODEX_SRC" 2>&1) || rc=$? + if [ "$rc" != 0 ]; then + case $out in + *"already added from a different source"*) + stale=$(printf '%s' "$out" | sed -n "s/.*marketplace '\([^']*\)' is already added.*/\1/p" | head -n 1) + [ -n "$stale" ] || stale=$CODEX_MKT + say "Codex marketplace '$stale' was added from another source; replacing it" + "$CODEX_BIN" plugin marketplace remove "$stale" + "$CODEX_BIN" plugin marketplace add "$CODEX_SRC" ;; + *) + printf '%s\n' "$out" >&2 + fail "could not add the Codex marketplace (exit $rc)" ;; + esac + else + [ -z "$out" ] || printf '%s\n' "$out" + fi # `add` returns early for a known source without fetching; only `upgrade` # refreshes a Git snapshot. A local-directory source is always current. if [ "$MODE" != staging ] && ! "$CODEX_BIN" plugin marketplace upgrade "$CODEX_MKT"; then @@ -305,10 +442,38 @@ if [ -n "$CURSOR_BIN" ]; then note "Cursor: open Customize, find MemHub and select Add — or, if it is already installed, Update (or remove and Add again) to get the new version. Cursor has no command for this step" fi +step "Repositories" +# Which repositories MemHub is active in (the plugin's scripts/repo_allow.py +# reads this file on every hook). No file: every repository. A file: only the +# ones it lists; everywhere else the plugin is dormant — no team rules, no +# capture, no companion — so a client's repository is never touched. +if [ "$REPOS_MODE" = list ]; then + write_repos "$REPOS" +elif [ "$REPOS_MODE" = all ]; then + if [ -e "$REPOS_FILE" ]; then rm -f "$REPOS_FILE"; say "removed $REPOS_FILE: MemHub is active in every repository" + else say "MemHub is active in every repository (no $REPOS_FILE)"; fi +elif [ "$REPOS_MODE" = selected ]; then + # Chosen, or nobody to ask: active nowhere until a repository is added, + # never in a repository nobody chose. + write_repos "" + note "Repositories: MemHub is active in none yet. In each repository you want it in, run /$PLUGIN:repos add . (or re-run this installer with --repos org/a,org/b, or --all-repos)" +elif [ -e "$REPOS_FILE" ]; then + say "kept $REPOS_FILE (--repos, --selected-repos or --all-repos changes it; /$PLUGIN:repos list shows it)" +else + say "MemHub is active in every repository (no $REPOS_FILE; --repos org/a,org/b limits it)" +fi + if [ "$LOGIN" = 1 ]; then step "Sign in (once for this machine)" + # With $MEMHUB_TOKEN set (a cloud or CI machine) --status passes on the + # variable alone, yet the MCP tools never see it: Claude Code strips it from + # the plugin's headersHelper. A full login with it set opens no browser and + # stores the key where the tools read it. if [ -z "$PLUGIN_ROOT" ]; then say "skipped: no installed copy to sign in from yet; after adding it in Cursor, ask the agent to log in to MemHub" + elif [ -n "${MEMHUB_TOKEN:-}" ]; then + python3 "$PLUGIN_ROOT/scripts/login.py" --host "$LOGIN_HOST" \ + || fail "\$MEMHUB_TOKEN was refused; check the key and run this installer again" elif python3 "$PLUGIN_ROOT/scripts/login.py" --status; then say "already signed in" else diff --git a/plugins/memhub/.claude-plugin/plugin.json b/plugins/memhub/.claude-plugin/plugin.json index 4e957733..5dc4de32 100644 --- a/plugins/memhub/.claude-plugin/plugin.json +++ b/plugins/memhub/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "memhub", "displayName": "XTrace MemHub", "description": "Team memory for Claude Code. Your sessions are saved automatically, so past work is searchable later. Your team's rules show up while you work, at the moment they apply. Skills let you search what the team knows, save a document, import a session, hand work to a teammate, link sessions to pull requests, write rules, work from specs, and watch a pull request through review.", - "version": "0.124.0", + "version": "0.133.0", "license": "Apache-2.0", "hooks": ["./hooks/claude-hooks.json"], "types": "./types/index.d.ts", @@ -32,6 +32,13 @@ "description": "When a turn looks like a correction worth keeping, the Stop hook asks Claude to start a background fork of the session, which drafts a team rule and files it as proposed (nothing is active until a reviewer activates it). The fork runs on your own model usage. Turn this off to draft nothing: the Stop hook then judges no turn.", "default": true, "required": false + }, + "companion": { + "type": "boolean", + "title": "Show the companion animal above the prompt", + "description": "The small pixel animal above the Claude Code prompt that shows what MemHub is doing: asleep while idle, up when a team rule fires, asking when a new rule is proposed. Off by default: a plain one-line MemHub status stands in its place (the last rule that fired, a proposed rule with its Activate / Reject / Later buttons, else what MemHub is doing here). Turn this on, or run any animal's command (/goose, /hippo, /penguin, /shiba) in a session, to see the animal instead; / off returns to the status line.", + "default": false, + "required": false } } } diff --git a/plugins/memhub/.codex-plugin/plugin.json b/plugins/memhub/.codex-plugin/plugin.json index db4d3f18..ab92991f 100644 --- a/plugins/memhub/.codex-plugin/plugin.json +++ b/plugins/memhub/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", - "description": "XTrace MemHub team memory in Codex: automatic session capture, artifact sync, plus skills for setup, login, onboarding, artifact upload, session import, search, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook \u2014 tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions, each filed with its categories and how many past sessions it would have fired in (start-rulebook), and PR babysitting. A rule is filed into a scope \u2014 everyone in the organisation (the default), just you, or a workspace you name \u2014 and reaches exactly those people. Run memhub:setup to verify hook routing and authentication. Current desktop builds support bundled hooks; older hosts need the user bridge. Bundled handlers defer to matching installed user bridge handlers, which require trust. Codex's own threads are not captured as your sessions: spawned subagents, guardian action-reviews and memory consolidation are recognised from the rollout's thread_source and skipped by name, while every other kind \u2014 including a product surface we have not seen, since Codex's ThreadSource type is open-ended \u2014 is captured, because refusing an unknown kind would silently and unrecoverably drop real work. Every rule fire is reported under the same namespaced session id capture uploads, so a fire is linked to the Codex session it fired in. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (map your own unlinked PRs, or the ones you name, to the sessions on this machine that wrote their code: scan local session history once, rank the candidates per PR by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt, one per session picked from its session id (/goose, /hippo, /penguin and /shiba switch it for the session; `always` pins one for every session): asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call \u2014 or presenting a rule the harness just proposed with Activate / Reject / Later buttons (activating takes an org admin, or you for a rule that applies to just you; the animal says so to anyone else). Clicking the \u2665 beside it pets it, and a proposal's rule\u2197 opens that rule in MemHub Studio. It is a Claude Code mod, and mods are on by default from Claude Code 2.1.287 (the desktop Code tab from 2.1.286), so it loads with the plugin with nothing to turn on — the old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS variable is ignored; disableAllHooks, --safe-mode, an organization policy or Anthropic switching mods off remotely stops it. /memhub:companion checks that and shows or hides it.", - "version": "0.124.0", + "description": "Team memory for Codex. Your sessions are saved to your memory automatically, your team keeps a shared brain of specs and docs, and your team's rules warn or block the agent at the moment they apply. MCP tools let the agent search and save team memory. Older Codex builds need a one-time hook install: ask the agent to set up MemHub's Codex hooks.", + "version": "0.133.0", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/.cursor-plugin/plugin.json b/plugins/memhub/.cursor-plugin/plugin.json index 8737dc91..f222c671 100644 --- a/plugins/memhub/.cursor-plugin/plugin.json +++ b/plugins/memhub/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "memhub", "displayName": "MemHub", - "description": "XTrace MemHub team memory in Cursor: search_memory / save_artifact / import_conversation tools plus skills for setup, login, onboarding, artifact upload, session import, recall, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook \u2014 tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions, each filed with its categories and how many past sessions it would have fired in (start-rulebook), and PR babysitting. A rule is filed into a scope \u2014 everyone in the organisation (the default), just you, or a workspace you name \u2014 and reaches exactly those people. Sessions are captured automatically. Session \u2194 PR linking ships as skills on Cursor: link-pr and find-contributing-sessions. Cursor's shell hooks are observational \u2014 `afterShellExecution` defines no reply the agent can see \u2014 so the automatic post-`gh pr create` link that Claude Code and Codex get is not available here. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt, one per session picked from its session id (/goose, /hippo, /penguin and /shiba switch it for the session; `always` pins one for every session): asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call \u2014 or presenting a rule the harness just proposed with Activate / Reject / Later buttons (activating takes an org admin, or you for a rule that applies to just you; the animal says so to anyone else). Clicking the \u2665 beside it pets it, and a proposal's rule\u2197 opens that rule in MemHub Studio. It is a Claude Code mod, and mods are on by default from Claude Code 2.1.287 (the desktop Code tab from 2.1.286), so it loads with the plugin with nothing to turn on — the old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS variable is ignored; disableAllHooks, --safe-mode, an organization policy or Anthropic switching mods off remotely stops it. /memhub:companion checks that and shows or hides it.", - "version": "0.124.0", + "description": "Team memory for Cursor. Your sessions are saved to your memory automatically, your team keeps a shared brain of specs and docs, and MCP tools let the agent search and save team memory. Team rules that block a shell command or a file read run at the moment they apply; rules on edits run on Claude Code and Codex, not yet on Cursor.", + "version": "0.133.0", "license": "Apache-2.0", "author": { "name": "XTrace" diff --git a/plugins/memhub/.mcp.json b/plugins/memhub/.mcp.json index 6e6cebee..dd2a9f5d 100644 --- a/plugins/memhub/.mcp.json +++ b/plugins/memhub/.mcp.json @@ -4,7 +4,7 @@ "type": "http", "url": "https://api.memhub.xtrace.ai/mcp-server/mcp", "headers": { - "X-MemHub-Plugin-Version": "0.124.0" + "X-MemHub-Plugin-Version": "0.133.0" }, "headersHelper": "python3 -s -E \"${CLAUDE_PLUGIN_ROOT}/scripts/mcp_headers.py\"", "auth": { diff --git a/plugins/memhub/README.md b/plugins/memhub/README.md index 8a80481e..6d2c2eed 100644 --- a/plugins/memhub/README.md +++ b/plugins/memhub/README.md @@ -97,6 +97,19 @@ Neither the option nor `$MEMHUB_TOKEN` reaches the MCP tools, because Claude Code gives the `headersHelper` neither of them. Skill scripts that run through the Bash tool don't see the option either. +**Cloud and CI machines.** No browser can sign them in, so give them a +personal access key instead. Set `MEMHUB_TOKEN=mhk_…` as a secret in the +environment, and run the installer from its setup script: + +```sh +curl -fsSL https://raw.githubusercontent.com/XTraceAI/agent-plugins/main/install.sh | sh +``` + +With `$MEMHUB_TOKEN` set, the sign-in step opens no browser. Once the server +accepts the key, it is stored as the machine's key, so the MCP tools use it +as well as the hooks. The environment's network policy must allow +`api.memhub.xtrace.ai`. + ## Skills Each skill runs as `/memhub:`, or when you ask for it in plain words. @@ -105,17 +118,17 @@ Each skill runs as `/memhub:`, or when you ask for it in plain words. | --- | --- | | `login` | Signs the plugin in and stores its access key (see Authentication). | | `onboard` | Signs the plugin in when it is not (as `login`), checks capture health, then creates or reuses the repository's agent brain and records it in `rooms.json`. Lists the repository's tracked Markdown files (`git ls-files`) and uploads the ones that look important to that brain without asking. It also saves a short repo overview it writes. With `MEMHUB_HARNESS_EXTRACT=1` on Claude Code it adds `CLAUDE_CODE_FORK_SUBAGENT=1` under `env` in `~/.claude/settings.json`. On Codex it installs the Codex hooks bridge (see [Codex and Cursor](#codex-and-cursor)) and asks you to approve it in Codex. `--status` only reports; `--remove` takes those machine settings out. | -| `save-artifact` | Uploads a file you name as an artifact, to the repository's brain when there is one, else to your personal memory. | -| `import-session` | Reads a past Claude Code, Codex or Cursor transcript from this machine and uploads it to your personal memory, in chunks when it is large. | -| `search-memory` | Read-only search of the brain and your memory through the MCP tools. | +| `save-artifact` | Uploads a file you name, as it is on disk, to the repository's shared brain when there is one, else to your personal memory. Saving under an existing name adds a new version. | +| `import-session` | Reads a past Claude Code, Codex or Cursor transcript from this machine and uploads it to your personal memory (never a team brain), in chunks when it is large. For sessions automatic capture missed: from before install, a failed upload, or a host without capture hooks. | +| `search-memory` | Read-only. Answers what the team knows or decided by searching the repository's brain and your personal memory with the same query, opening the best match, and saying which one each answer came from. | | `handoff-session` | Writes a handoff brief (goal, state, decisions, next steps, gotchas) into the standing handoff brain for exactly you and the teammates you name, so they can search it from their own agent; creates and shares that brain when there is none, or a one-off brain with `--new`. The session itself is not shared. | -| `link-pr` | Links or unlinks a session and a pull request in MemHub. Asks the agent to run `gh pr view`. | -| `find-contributing-sessions` | Reads this machine's Claude Code, Codex and Cursor session history to find the sessions behind your pull requests. Its script runs `gh pr view` and `gh api` for each PR's files. It prints paths, branches and commit ids, never transcript content, and links only the sessions you approve. | -| `pr-babysit` | Polls a pull request's review bots and CI with `gh`. The agent fixes findings, commits, pushes and replies on review threads. Once the PR is clean, it saves a review record to the repository's brain. The `pr_babysit_trigger` hook asks the agent to start it after `gh pr create`. | -| `create-rule` | Drafts a team rule, replays it over this machine's sessions, and files it as `proposed` with `create_rule`. | +| `link-pr` | Links or unlinks a session and a pull request in MemHub. Asks the agent to run `gh pr view`. With `--find`, reads this machine's Claude Code, Codex and Cursor session history to find the sessions behind your pull requests: its script runs `gh pr view` and `gh api` for each PR's files, prints paths, branches and commit ids, never transcript content, and the skill links only the sessions you approve. | +| `pr-babysit` | Polls a pull request's review bots and CI with `gh`. The agent fixes findings, commits, pushes and replies on review threads. Once the PR is clean, it files a `proposed` rule with `create_rule` for any fixed finding whose mistake would recur. The `pr_babysit_trigger` hook asks the agent to start it after `gh pr create`. | +| `create-rule` | Turns a lesson into a rule that warns the agent or stops the command when its situation comes up. The rule is for just you unless it is plainly right for everyone working in the repo or the organisation. Drafts the check, replays it over this machine's sessions, and files it for review as `proposed`; you turn on your own rules, and an org admin turns on the team's. | | `start-rulebook` | Fills starter rules in from a scan of the repository. If you choose, it also mines rules from your CLAUDE.md, the last 30 days of Claude Code, Codex and Cursor sessions on this machine, and Claude Code `/insights` facets. Working files go to a temp directory or `mine-out/` in the current directory. It files rules as `proposed` and never activates one. | | `spec`, `spec-work`, `spec-check`, `spec-maintain` | Spec-driven work against Git specs or brain documents. A `--cloud` bootstrap runs on the MemHub backend. | -| `companion` | Checks, shows or hides the companion (see [Companion](#companion-claude-code-21287)). | +| `companion` | Checks, shows or hides the companion animal; with it hidden a one-line status strip stands in its place (see [Companion](#companion-claude-code-21287)). | +| `repos` | Lists, adds or removes the repositories MemHub is active in on this machine, or clears the list so it is active everywhere (see [Which repositories MemHub is active in](#which-repositories-memhub-is-active-in)). Outside the listed repositories the plugin does nothing: no rules, no capture, no companion. | ## What it runs, sends, and fetches @@ -138,12 +151,55 @@ loads `hooks/claude-hooks.json` without setting it gets the path PreToolUse, UserPromptSubmit and Stop as a block. Set `CLAUDE_PLUGIN_ROOT` to the plugin folder when you run these hooks in another host. +#### Which repositories MemHub is active in + +You can limit MemHub to the repositories you choose, so a client's repository +is never touched. The list is `~/.config/memhub-plugin/repos.json` +(`{"version": 1, "mode": "allowlist", "repos": ["XTraceAI/MemHub-Backend"]}`), +managed by `/memhub:repos` (`list`, `add .`, `add org/name`, `remove`, `all`) +or by `install.sh --repos org/a,org/b` / `--selected-repos` / `--all-repos` +(a first install asks). A repository is named +as its `origin` remote spells it, `/`, matched case-insensitively; a +checkout with no remote is named by its directory. Linked worktrees carry +their repository's name. + +- **No file:** MemHub is active in every repository (every install before + this option existed, and any upgrade). +- **A file:** everywhere it does not list, the plugin is dormant — every + session-wide hook in the table below (capture, Markdown capture, the brief, + the harness, PR linking, the Rulebook's session and prompt lanes) is + skipped by `hook_entry.py` for a session whose directory is not in a listed + checkout, the Rulebook's per-call lanes (`rulebook_hook pre` / `post`) judge + each tool call by the checkout it acts in and stay silent for an unlisted + one, and the Claude Code mod claims no lane and shows no animal there. The + only hook that still runs is the rule-fire upload, which ships rows already + recorded. A session in an unlisted repository is told so once, at its start, + through `additionalContext`; nothing else prints. A session started in a + folder that is not a repository (the parent of several) is not captured, but + its tool calls into a listed checkout are still judged. An **empty** list is + dormant everywhere; `install.sh` writes one on a first install it cannot + ask about (`/memhub:repos add .` in each repository you want, or + `/memhub:repos all`). A file that cannot be read counts as empty. +- **Asked, not guessed (ENG-1253):** in Claude Code, a session started in a + checkout the list leaves out is asked once, on the status strip above the + prompt: `MemHub · enable in ?` with **1 Yes / 2 No / 3 Later**. + Yes lists it, exactly as `/memhub:repos add .` does, and MemHub is active + there from that moment (the hooks read the file per call); No records it + under `declined` in the same file, so it stays dormant and is never asked + about again — `/memhub:repos list` and `status` show declined repositories, + and `add` or `remove` clears a decline; Later hides the ask for that session + only. Nothing asks when there is no file, outside a checkout, in a listed or + declined repository, or while no one is signed in. + +The list applies to the Claude Code hooks and to every host's Rulebook check +(`rulebook_hook.py`, which Codex and Cursor call too). Codex and Cursor's own +capture paths do not read it yet. + | Event (matcher) | Name | What it does | Sends data? | | --- | --- | --- | --- | | PreToolUse (`Bash`, `Edit`, `MultiEdit`, `Write`, `NotebookEdit`, `Read`) | `rulebook_hook pre` | Checks the call against your cached team rules. It can add an advisory or **deny** the call when a gate rule matches. It refreshes the rule cache in a detached background process once the cache is a minute old. | Yes, see [Rulebook](#rulebook) | -| PreToolUse (`mcp__*__add_memory`) | `add_memory_gate` | Denies MemHub's `add_memory` while this plugin is already capturing the session, so a turn isn't stored twice. | No | | PreToolUse (`mcp__*__create_rule`) | `create_rule_origin` | Adds this session's id and the current turn to MemHub's `create_rule` call (not to a harness draft, which carries its own), so the rule records the session it was filed from. Never approves or blocks the call. | Yes, the session id and turn, inside the `create_rule` call | -| PostToolUse (`Bash`) | `flush_session` | After a command that actually ran `git commit`, `gh pr create` or `gh pr merge`, uploads the transcript so far. Runs in the background. | Yes, the transcript | +| PostToolUse (`Bash`) | `flush_session` | After a command that actually ran `git commit`, `gh pr create` or `gh pr merge`, uploads the transcript so far, then each agent the session spawned (`subagents/`) as its own conversation linked to it — never the MemHub harness fork or a plugin's own fork, and only to a server that accepts agents. Runs in the background. | Yes, the transcript and its agents' | | PostToolUse (`Edit`, `MultiEdit`, `Write`, `NotebookEdit`) | `artifact_sync_reminder` | If the edited file belongs to a spec in the repository (by spec frontmatter), reminds the agent once per session. | No | | PostToolUse (`Bash`) | `pr_babysit_trigger` | After a successful `gh pr create`, tells the agent to start a `/memhub:pr-babysit` loop on the new PR. | No | | PostToolUse (`Edit`, `MultiEdit`, `Write`, `Bash`) | `md_capture` | Records which Markdown files the session wrote, or for Bash the working directory, in a local state file, and touches a per-session activity marker. | No | @@ -160,7 +216,7 @@ the plugin folder when you run these hooks in another host. | Stop | `md_capture_flush` | Saves qualifying Markdown files as draft artifacts. Runs in the background. See [Markdown capture](#markdown-capture). | Yes, file contents | | Stop | `rulebook_hook flush` | Uploads the rule-fire and rule-event logs. Once a day it also deletes stale local state. Runs in the background. | Yes, identifiers | | Stop | `harness_stop stop` | Unless `MEMHUB_HARNESS_EXTRACT` turns it off. | Yes, when on | -| SessionEnd | `session_end` | Runs `flush_session.py` (re-sends the whole transcript as a backstop), then `rulebook_hook flush final`. Runs in the background. | Yes, the transcript | +| SessionEnd | `session_end` | Runs `flush_session.py` (re-sends the whole transcript as a backstop, then the session's agents as above), then `rulebook_hook flush final`. Runs in the background. | Yes, the transcript and its agents' | ### Session capture @@ -287,11 +343,32 @@ prompt text), `offsets/` (where the next transcript read may start) and `companion/register.ts` is part of the plugin's Claude Code mod (a function-hook module: `hooks/hooks.json` loads `mod/register.ts`, which registers the companion last). Mods are on by default from Claude Code 2.1.287 -(the desktop Code tab from 2.1.286) and load with the plugin; there is -nothing to turn on, and `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is ignored. -`disableAllHooks`, `--safe-mode`, an organization policy or Anthropic -switching mods off remotely stops it. `/ off` hides it. It writes nothing to `~/.claude/settings.json`. The -module draws an animal above the prompt and reacts to session, turn, prompt +(the desktop Code tab from 2.1.286) and load with the plugin; +`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is ignored. `disableAllHooks`, +`--safe-mode`, an organization policy or Anthropic switching mods off remotely +stops the mod altogether. It writes nothing to `~/.claude/settings.json`. + +**The animal is off by default.** The `companion` option (`/plugin` → +MemHub → configure, or `claude plugin install … --config companion=true`; +`install.sh --companion`; a first install asks) turns it on for every session. With the option off, +the module draws a plain one-line **status strip** above the prompt in the +animal's place, so you can still see MemHub working: the last rule that fired +(`📏 Rule fired: …`, `⛔️ Blocked: …`), a proposed rule with the same +**1 Activate / 2 Reject / 3 Later** buttons and its name as the link to +MemHub Studio, else a dim `MemHub · N rules armed for ` / +`MemHub · capturing` / `MemHub · inactive in this repo (/memhub:repos add .)`. +The strip never animates. In a repository the list leaves out — neither +listed nor declined — it asks once, at session start, `MemHub · enable in +?` with **1 Yes / 2 No / 3 Later** (ENG-1253): Yes lists the +repository (what `/memhub:repos add .` writes) and the line flips to the active +one at once; No records it as `declined` in the same file, so it is never asked +again; Later hides the ask for that session. Any animal's command (`/goose`, `/hippo`, +`/penguin`, `/shiba`) shows the animal in that session and remembers the +choice; `/ off` returns to the strip. In a repository the +[repository list](#which-repositories-memhub-is-active-in) leaves out, the +strip says MemHub is inactive and the animal stays away. + +With the animal on, the module reacts to session, turn, prompt and tool events, including rule fires. It saves its preferences in Claude Code's plugin store. It reads the rules that fired from the mod's own session state, and the classic hook answer only where the mod's Rulebook @@ -321,6 +398,22 @@ handlers, backs up a file it changes, and copies a launcher to `$CODEX_HOME/memhub_hook_bridge.py`. Codex asks you to review the hooks before they run. +On Cursor, `scripts/cursor_rulebook.py` runs team rules through the same +`rulebook_hook.py pre` call Claude Code uses. A shell command is judged at +`beforeShellExecution` and a file read at `beforeReadFile`. Cursor's +`preToolUse` for Read leaves out the agent's offset and limit, so the line +count comes from the slice Cursor is about to send to the model. A deny keeps +that slice out of the model and puts the rule's words in `user_message`, the +one field Cursor shows the agent on a deny. A read is judged only when the +agent's own Read tool asked for that file; a file you attached is not +judged. A Cursor subagent runs under its own conversation id, so +`sessionStart` marks your conversation as the main one, and a `Task` call in +flight marks any other conversation as its subagent, which `given.agent.main` +rules leave alone. That state lives in `cursor/` under the rulebook +directory. At `stop` and `sessionEnd` the rule fires are sent to MemHub, as +the Stop hook does on the other hosts. Rules on edits do not run on Cursor: +Cursor has no hook before an edit that the plugin uses. + ### Network destinations - `https://api.memhub.xtrace.ai`: the MCP server (`/mcp-server/mcp`) and REST @@ -333,6 +426,13 @@ before they run. also carry an `X-MemHub-Plugin-Version` header and don't follow redirects. The access-key calls (`scripts/pak.py`) carry no version header and use Python's default URL opener, which follows a redirect on a GET. + Every request verifies the server's certificate: against Python's own + bundle, and when that bundle is empty or rejects the issuer, against the + roots the machine already trusts — `certifi`, the OS bundle, the macOS + keychains (`scripts/_tls.py`). A python.org build of Python has an empty + bundle until its "Install Certificates.command" is run; without this, + every call from it failed with CERTIFICATE_VERIFY_FAILED. Verification + is never skipped, and no root ships with the plugin. - `https://mem.xtrace.ai`: the plugin sends nothing here. After `/memhub:login` succeeds, your browser is sent to the setup guide on this site. @@ -352,7 +452,7 @@ The plugin itself makes no other network calls. In particular, it makes no calls to the GitHub API of its own. GitHub traffic comes only from `gh` and `git`: the commands the agent runs, for example in `/memhub:pr-babysit` or `/memhub:link-pr`, and the `gh pr view` and `gh api` calls the -`find-contributing-sessions` script makes with your `gh` login. +`link-pr --find` script makes with your `gh` login. ### Commands it starts @@ -366,9 +466,9 @@ calls to the GitHub API of its own. GitHub traffic comes only from `gh` and browser opens for `/memhub:login`, and for the `save-artifact`, `onboard` and `import-session` scripts when they find no access key or usable cached login. -- The plugin's hooks never run `gh`. The `find-contributing-sessions` script - runs `gh pr view` and `gh api`. The `pr-babysit`, `link-pr` and - `find-contributing-sessions` skills ask the agent to run `gh`, and +- The plugin's hooks never run `gh`. The `link-pr --find` script + runs `gh pr view` and `gh api`. The `pr-babysit` and `link-pr` + skills ask the agent to run `gh`, and `pr-babysit` also has it commit, push and reply on review threads. ### Local state @@ -382,6 +482,9 @@ Everything lives under `~/.config/memhub-plugin/` unless noted: - `rulebook/`: cached rule books, per-session state, the fire and event logs; - `overview/`: brain-brief caches; - `rooms.json`: which brain each repository maps to; +- `repos.json`: the repositories MemHub is active in, when you limited it + (`/memhub:repos`), and under `declined` those you answered No to when the + status strip asked; absent means every repository; - `prlink/`: the PR-link negative cache; - `compatibility/`, `releases/`: upgrade-check results; - `harness/stop.log`, `harness/offsets/`, `harness/judged/`: unless `MEMHUB_HARNESS_EXTRACT` turns the harness off; @@ -420,6 +523,9 @@ them reads one, and none of that text is ever run: | Setting | Effect | | --- | --- | | `memhub_token` (userConfig) | Credential for hooks, ahead of everything else | +| `harnessDrafting` (userConfig) | `false` turns off harness-tied rule drafting (on by default) | +| `companion` (userConfig) | `true` shows the companion animal above the Claude Code prompt in every session; off (the default) draws a one-line MemHub status strip in its place. `/` and `/ off` override it per machine | +| `MEMHUB_REPOS_FILE` | Moves the repository list (`repos.json`, see [Which repositories MemHub is active in](#which-repositories-memhub-is-active-in)) | | `MEMHUB_TOKEN` | Credential for hooks and scripts when the option is unset | | `MEMHUB_TURN_FLUSH=0` | Turns off per-turn capture, and with it the session-start capture-health, compatibility and update notices | | `MEMHUB_RULEBOOK_RECALL=0` | Turns off anchor rules (rules tied to files or commands, matched locally) | @@ -432,13 +538,13 @@ them reads one, and none of that text is ever run: | `MEMHUB_PRLINK_NEGATIVE_TTL_S` | PR-link negative cache lifetime; `0` turns it off | | `MEMHUB_RULEBOOK_BASE`, `MEMHUB_STATE_DIR`, `MEMHUB_ROOMS_FILE`, `MEMHUB_HARNESS_DIR` | Move local state | | `MEMHUB_SPEC_DIR` | Where the spec reminder and onboarding look for specs | -| `MEMHUB_PLUGIN_SCRIPTS` | Where the `find-contributing-sessions` and `start-rulebook` scripts load the plugin's scripts from, ahead of this plugin's own folder | +| `MEMHUB_PLUGIN_SCRIPTS` | Where the `link-pr --find` and `start-rulebook` scripts load the plugin's scripts from, ahead of this plugin's own folder | | `MEMHUB_RULEBOOK_BASE_BRANCH` | Base branch for rule diff checks | | `MEMHUB_FLUSH_DEADLINE_S`, `MEMHUB_TURN_FLUSH_TIMEOUT_S`, `MEMHUB_RULEBOOK_TIMEOUT_S`, `MEMHUB_HARNESS_CLASSIFY_TIMEOUT`, `MEMHUB_OAUTH_TIMEOUT`, `MEMHUB_OAUTH_BIND_TIMEOUT` | Timeouts | | `MEMHUB_PAK_LABEL` | Label for the access key `/memhub:login` mints | | `MEMHUB_RULEBOOK_DEBUG`, `MEMHUB_HARNESS_DEBUG` | Print hook tracebacks to stderr | | `MEMHUB_RULEBOOK_HOOK_VERSION` | Test override for the hook version that rules' minimum-version checks compare against | -| `MEMHUB_HARNESS_CHILD` | Marks a `claude` process started by XTrace's test tooling: it is not captured, `add_memory` is not gated, and harness drafting is off. The plugin never sets it | +| `MEMHUB_HARNESS_CHILD` | Marks a `claude` process started by XTrace's test tooling: it is not captured, and harness drafting is off. The plugin never sets it | ## License diff --git a/plugins/memhub/companion/register.ts b/plugins/memhub/companion/register.ts index 01e2c57f..8fe0c071 100644 --- a/plugins/memhub/companion/register.ts +++ b/plugins/memhub/companion/register.ts @@ -1,8 +1,21 @@ // One entry point: mod/register.ts, the plugin's hooks module (hooks.json), -// calls `register(on, ctx)` with the mod's Ctx (mod/ctx.ts), last of its -// parts, so the companion's hooks sit innermost. MemHub calls use -// `ctx.api()`: the REST base, the bearer, and the Studio web app paired with -// the base; `ctx.env` says which MemHub it is. +// calls `register(on, ctx, options)` with the mod's Ctx (mod/ctx.ts) and the +// plugin's userConfig values, last of its parts, so the companion's hooks sit +// innermost. MemHub calls use `ctx.api()`: the REST base, the bearer, and the +// Studio web app paired with the base; `ctx.env` says which MemHub it is. +// +// Whether the ANIMAL shows: the `companion` option (plugin.json userConfig, +// off by default) shows it in every session unless `/ off` hid it; +// with the option off it shows only where the person ran `/` (which +// stores `companion.enabled`). And never in a repository MemHub is inactive +// in — `ctx.active()`, the machine's repository allowlist (repos.json) as the +// shell read it at boot — where the Python lanes are silent too. With the +// animal off the band is not empty: one plain status line (the "strip", +// ui.render below) stands in its place, so MemHub's presence stays visible +// and a proposed rule can still be answered on its buttons. In a repository +// the list leaves out — not listed, not declined — the strip asks once, at +// session start, whether to enable MemHub there (ENG-1253): `1 Yes` lists +// it, `2 No` declines it for good, `3 Later` hides the ask for the session. // // It reads what the mod writes to `$.state` (fires, proposals, // lanes) and never needs the classic.* events, which the built-in @@ -10,10 +23,10 @@ // Mods Migration Spec §1.4, D3). The classic PreToolUse / PostToolUse hooks // stay only as the fallback for lanes the mod has not claimed. -import type { EngineInterface, On, Timer } from 'claude-code' +import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code' import { read, update } from 'claude-code' -import type { Api, Ctx } from '../mod/ctx' +import { type Active, type Api, type Ctx, lastJson, MOD_CLI, type SignIn } from '../mod/ctx' import type { FireNote, ProposalNote } from '../types' import type { Animal, Build, Painted, Tone } from './animal' import { ANIMALS, DEFAULT_ANIMAL } from './animals' @@ -38,6 +51,14 @@ const PROPOSALS = { plugin: 'memhub', key: 'proposals' } as const const LANES = { plugin: 'memhub', key: 'lanes' } as const /** How often the server is asked for proposed rules, besides session start and each turn's end. */ const POLL_MS = 300_000 +/** What the animal says, once a session, when the poll finds no one signed in. */ +const SIGNED_OUT_SAID = "Not signed in to MemHub: sessions aren't saved and team rules can't sync. Run /memhub:login" +/** What it says when a later poll finds the sign-in it had asked for. */ +const SIGNED_IN_SAID = 'Signed in to MemHub: sessions are saved and team rules sync again.' +/** The row under the animal while no one is signed in. */ +const SIGNED_OUT_ROW = 'not signed in · /memhub:login' +/** The strip's enable ask (ENG-1253): the keyed Box a test (and a surface) finds. */ +const ENABLE_KEY = 'memhub-enable-ask' /** * The companion in the band above the prompt: MemHub's face in the session, @@ -47,6 +68,8 @@ const POLL_MS = 300_000 * crossly for a call the rule stopped — and when the harness has proposed a * new rule, presenting it and asking: Activate, Reject or Later, as buttons * inside its bubble (1, 2, 3 from an empty prompt) until it is answered. + * When no one is signed in to MemHub it says so, once a session, and keeps a + * `not signed in · /memhub:login` row under it until a sign-in is found. * * This is the whole of what watches the session. Which animal does the * sleeping and speaking is the Animal contract's business (see animal.ts): @@ -78,6 +101,16 @@ const FPS = 20 const DESKTOP_STRIDE = 1 const RASTER_KEY = 'companion' const STORE_ENABLED = 'companion.enabled' +/** The userConfig option that shows the companion without a `/` (off by default). */ +const OPTION_COMPANION = 'companion' +/** How long setUp waits for the shell's allowlist answer before showing the animal anyway. */ +const ACTIVE_WAIT_MS = 5_000 +const REPOS_FILE = '~/.config/memhub-plugin/repos.json' +/** How many turn ends a fire stands on the status strip: the turn it fired in, and the next. */ +const STRIP_FIRE_TURNS = 2 +const STRIP_KEY = 'memhub-strip' +/** The strip's colour for a fire that blocked the call (the Python disclosure's ⛔️ line). */ +const BLOCKED_HEX = '#e06c75' // `companion.animal`, the old global pick, is neither read nor written: most // of it was `/goose` run to turn the band on, not a choice. Older versions // still read it, so it is left where it is. @@ -141,7 +174,20 @@ type Band = { wordKey: string /** The tone of what the animal is saying now, while a `speak` plays. */ speakingTone: Tone | null + /** + * The ANIMAL is drawn: decided by setUp from the option, the store and the + * allowlist; false until then. False draws the status strip instead (one + * dim line: the latest fire, a waiting proposal with its buttons, else what + * MemHub is doing), never nothing — so the mod's presence stays visible. + */ isEnabled: boolean + /** The plugin's `companion` option: the animal without a `/` first. */ + optionOn: boolean + /** The repository allowlist's answer for this session (ctx.active()); null until setUp asked. */ + active: Active | null + /** The fire the strip shows (no bubble to say it in), for `stripFireTurns` more turn ends. */ + stripFire: Said | null + stripFireTurns: number /** A forced pixel size, or null to let the room pick as hippo_half.py does. */ scale: number | null /** Rows the band really gave the drawing, once it has said; see ui.render. */ @@ -205,9 +251,21 @@ type Band = { isPolling: boolean /** The MemHub the poll reached ('staging' | 'production'); '' until it has. */ env: string + /** The sign-in the last poll found: 'out' keeps a row under the animal; 'unknown' changes nothing. */ + signIn: SignIn + /** The session the animal last said it is not signed in, so it says so once a session. */ + saidSignedOutIn: string | null + /** The session's working directory (session.start): what the enable ask's Yes and No name to the CLI. */ + cwd: string + /** + * The strip's enable ask (ENG-1253): 'open' shows it where `enableAskOf` + * says to; 'answering' while Yes or No is being written (its buttons + * wait); 'later' hides it for the rest of this session, in memory only. + */ + enableAsk: 'open' | 'answering' | 'later' } -export function register(on: On, ctx: Ctx) { +export function register(on: On, ctx: Ctx, options: PluginOptions = {}) { const band: Band = { animal: DEFAULT_ANIMAL, build: DEFAULT_ANIMAL.builds[0]!, @@ -222,7 +280,11 @@ export function register(on: On, ctx: Ctx) { buttonsKey: '', wordKey: '', speakingTone: null, - isEnabled: true, + isEnabled: false, + optionOn: options[OPTION_COMPANION] === true, + active: null, + stripFire: null, + stripFireTurns: 0, scale: null, roomRows: null, roomAt: null, @@ -257,10 +319,15 @@ export function register(on: On, ctx: Ctx) { pollTimer: null, isPolling: false, env: '', + signIn: 'unknown', + saidSignedOutIn: null, + cwd: '', + enableAsk: 'open', } on('session.start', async ($, e, next) => { const result = await next(e) + if (typeof e.cwd === 'string') band.cwd = e.cwd // `claude -p` is a terminal that is not interactive, with no band to draw // in. The desktop reports no surface here and `isInteractive: false` (a // live 2.1.287 session), so it sets up from its first draw instead. @@ -281,7 +348,10 @@ export function register(on: On, ctx: Ctx) { band.petUntil = 0 await $.store.set(STORE_ENABLED, false).catch(() => undefined) $.ui.invalidate('ui.render') - return { text: `The ${animal.name.toLowerCase()} goes away. /${commandOf(animal)} brings it back.` } + return { + text: `The ${animal.name.toLowerCase()} goes away; a plain MemHub status line stays above the prompt. ` + + `/${commandOf(animal)} brings it back.`, + } } const scaleAsked = /^scale +([1-4])$/.exec(arg) if (scaleAsked || arg === 'scale auto') { @@ -315,6 +385,19 @@ export function register(on: On, ctx: Ctx) { if (isDemo && !(demoBuild.demo && (arg === 'demo' || (demoBuild.demoPoses ?? []).includes(arg)))) { return { text: `usage: /${commandOf(animal)} ${usageOf(animal)}` } } + // A repository MemHub is inactive in (the allowlist) has no companion + // to show, whatever the person asks of it: say so, and store nothing. + const active = await activeOf($, band) + if (!active.enabled) { + return { + text: active.repo + ? `MemHub is inactive in this repository (${REPOS_FILE} leaves it out), so the ` + + `${animal.name.toLowerCase()} stays away. /${$.plugin.name}:repos add . enables MemHub here.` + : `MemHub is inactive here: this folder is not inside a repository, and ${REPOS_FILE} limits ` + + `MemHub to the repositories it lists, so the ${animal.name.toLowerCase()} stays away. ` + + `Run /${$.plugin.name}:repos add . inside a repository to enable MemHub there.`, + } + } // this command decides the session's animal: a pick still in flight for // it yields, and one the engine's new session id (after a /clear or a // /resume) was waiting for is this one @@ -331,7 +414,8 @@ export function register(on: On, ctx: Ctx) { band.pendingPick = key isPending = true } - const isSwitch = band.animal !== animal || !band.isEnabled + const wasOff = !band.isEnabled + const isSwitch = band.animal !== animal || wasOff band.isEnabled = true band.animal = animal await $.store.set(STORE_ENABLED, true).catch(() => undefined) @@ -354,6 +438,8 @@ export function register(on: On, ctx: Ctx) { play(band, 'auto') } start($, band) + // polls stopped while it was off: look again now, sign-in included + if (wasOff && band.isSetUp) void pollProposals($, band) $.ui.invalidate('ui.render') if (isAlways) { return { text: `${key}: every new session starts with ${titleOf(animal).toLowerCase()} (\`/${key} random\` undoes it)` } @@ -405,6 +491,11 @@ export function register(on: On, ctx: Ctx) { band.isWorking = false band.activeUntil = band.frame + LINGER_FRAMES if (band.isSetUp) void pollProposals($, band) + // the strip's fire line stands through the turn it fired in and the next + if (band.stripFire && --band.stripFireTurns <= 0) { + band.stripFire = null + $.ui.invalidate('ui.render') + } } return result }) @@ -419,6 +510,7 @@ export function register(on: On, ctx: Ctx) { const result = await next(e) if (isClassicLane(await lanesNow($), 'pre')) { announce(band, firedOf(result.additionalContext ?? [], result.deny ?? result.ask).map(saidOfFire)) + if (!band.isEnabled) $.ui.invalidate('ui.render') // the strip has no frame clock to redraw it } return result }) @@ -427,6 +519,7 @@ export function register(on: On, ctx: Ctx) { const result = await next(e) if (isClassicLane(await lanesNow($), 'post')) { announce(band, firedOf(result.additionalContext ?? [], result.block).map(saidOfFire)) + if (!band.isEnabled) $.ui.invalidate('ui.render') } return result }) @@ -434,14 +527,90 @@ export function register(on: On, ctx: Ctx) { on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { // Read while drawing, so a write to either redraws the band and lands here. await feedFromState($, band) - if (!band.isEnabled || e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) { + // The desktop sets up from its first draw (session.start named no + // surface there), and that draw happens while the band is still hidden: + // setUp is what decides whether it shows, so it runs before that test. + if (e.surface === 'desktop') void setUp($, band) + if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) { band.requestId = null return next(e) } const { Box, Text, Button } = $.ui.resolve(e) band.requestId = e.requestId band.surface = e.surface - if (e.surface === 'desktop') void setUp($, band) + // opens a rule's page in MemHub Studio. A pressable word rather than a + // link: a terminal without hyperlinks draws a Link or a Markdown link as + // its text and then its URL, which ran down the bubble past its bottom. + const opens = (url: string) => () => void openUrl($, band, url) + // under the animal (or on the strip), the rule's name opens it (#67's link), as `rule` does + const name = (ask: Proposed, width: number) => + ask.url + ? Button({ key: 'rule-name', plain: true, label: nameOf(ask, width), hover: { underline: true }, onPress: opens(ask.url) }) + : Text({ children: nameOf(ask, width) }) + const controlsFor = (ask: Proposed) => { + const isDemo = ask === DEMO_ASK + const answer = (action: 'activate' | 'reject') => + isDemo ? () => demoPress($, band, action) : () => void decide($, band, ask, action) + // plain, so each reads `1: Activate`: the digit is the key to press + return band.isDeciding + ? [Text({ color: '#968ca5', children: 'asking MemHub…' })] + : isDemo && band.demoNote + ? [Text({ color: '#968ca5', wrap: 'truncate-end', children: band.demoNote })] + : [ + Button({ key: 'rule-activate', hotkey: '1', plain: true, label: 'Activate', onPress: answer('activate') }), + Button({ key: 'rule-reject', hotkey: '2', plain: true, label: 'Reject', onPress: answer('reject') }), + Button({ + key: 'rule-later', hotkey: '3', plain: true, dimColor: true, label: 'Later', + onPress: isDemo ? () => demoPress($, band, 'later') : () => later($, band), + }), + ] + } + const waiting = (ask: Proposed) => + Box({ key: 'rule-waiting', gap: 2, children: [Text({ dimColor: true, children: 'new rule waiting:' }), name(ask, 40), ...controlsFor(ask)] }) + // The enable ask (ENG-1253): the same three-button row as a proposal's, + // with its own three answers. The digits are the keys to press. + const asking = (identity: string) => + Box({ + key: ENABLE_KEY, gap: 2, children: [ + Text({ wrap: 'truncate-end', children: `MemHub · enable in ${identity}?` }), + ...(band.enableAsk === 'answering' + ? [Text({ color: '#968ca5', children: 'writing…' })] + : [ + Button({ key: 'enable-yes', hotkey: '1', plain: true, label: 'Yes', onPress: () => void answerEnable($, band, 'yes') }), + Button({ key: 'enable-no', hotkey: '2', plain: true, label: 'No', onPress: () => void answerEnable($, band, 'no') }), + Button({ key: 'enable-later', hotkey: '3', plain: true, dimColor: true, label: 'Later', onPress: () => enableLater($, band) }), + ]), + ], + }) + + // The status strip: the animal is off (the `companion` option, `/ + // off`, or a repository MemHub is inactive in), and one plain line stands + // in its place so the mod's presence stays visible. Its text, in this + // order: the latest fire, as the animal would say it; a waiting proposal + // with its 1 Activate / 2 Reject / 3 Later buttons and the rule's name as + // the link to Studio — the proposal flow needs no animal; the enable ask, + // where this repository is unlisted and undeclined (enableAskOf); else, + // dim, what MemHub is doing here. Nothing on it moves: no frame clock runs. + if (!band.isEnabled) { + const fire = band.stripFire + const ask = band.asks[0] + const enable = enableAskOf(band) + // a Text takes no key, so the keyed Box is what a test (and a surface) finds + const line = fire + ? Box({ + key: STRIP_KEY, + children: Text({ + wrap: 'truncate-end', ...(fire.tone === 'blocked' ? { color: BLOCKED_HEX } : {}), + children: `${fire.tone === 'blocked' ? '⛔️' : '📏'} ${fire.text}`, + }), + }) + : ask + ? waiting(ask) + : enable + ? asking(enable) + : Box({ key: STRIP_KEY, children: Text({ dimColor: true, wrap: 'truncate-end', children: quietOf($, band) }) }) + return Box({ key: 'memhub-strip-row', justifyContent: 'flex-end', children: line }) + } // hippo_half.py picks its pixel size from the terminal's height; the band // gets less than that, and only says how much by how much it scrolled. So // pick from the allowance, and where the drawing did not fit, remember the @@ -457,7 +626,9 @@ export function register(on: On, ctx: Ctx) { // under it; that row is kept while one waits, so the drawing never jumps. // On the desktop they always have that row (see the header). const isRowed = e.surface === 'desktop' ? ask !== undefined : band.mode === 'auto' && band.asks.length > 0 - const extra = isRowed ? 1 : 0 + // no one signed in: a row of its own under the animal, kept until a poll finds a sign-in + const isSignedOut = band.mode === 'auto' && band.signIn === 'out' + const extra = (isRowed ? 1 : 0) + (isSignedOut ? 1 : 0) const room = Math.max(1, Math.min(e.props.maxRows, band.roomRows ?? e.props.maxRows) - extra) const build = buildFor(band.animal, band.scale) if (build !== band.build) { @@ -478,39 +649,11 @@ export function register(on: On, ctx: Ctx) { band.roomRows = bodyRows $.ui.invalidate('ui.render') } - // opens a rule's page in MemHub Studio. A pressable word rather than a - // link: a terminal without hyperlinks draws a Link or a Markdown link as - // its text and then its URL, which ran down the bubble past its bottom. - const opens = (url: string) => () => void openUrl($, band, url) const petButton = () => Button({ key: PET_KEY, plain: true, dimColor: true, label: '♥', onPress: () => onClick(band) }) const at = buttonsAt(band, build, band.layout) band.buttonsKey = keyOfButtons(at) - // under the animal, the rule's name opens it (#67's link), as `rule` does - const name = (ask: Proposed, width: number) => - ask.url - ? Button({ key: 'rule-name', plain: true, label: nameOf(ask, width), hover: { underline: true }, onPress: opens(ask.url) }) - : Text({ children: nameOf(ask, width) }) - const controlsFor = (ask: Proposed) => { - const isDemo = ask === DEMO_ASK - const answer = (action: 'activate' | 'reject') => - isDemo ? () => demoPress($, band, action) : () => void decide($, band, ask, action) - // plain, so each reads `1: Activate`: the digit is the key to press - return band.isDeciding - ? [Text({ color: '#968ca5', children: 'asking MemHub…' })] - : isDemo && band.demoNote - ? [Text({ color: '#968ca5', wrap: 'truncate-end', children: band.demoNote })] - : [ - Button({ key: 'rule-activate', hotkey: '1', plain: true, label: 'Activate', onPress: answer('activate') }), - Button({ key: 'rule-reject', hotkey: '2', plain: true, label: 'Reject', onPress: answer('reject') }), - Button({ - key: 'rule-later', hotkey: '3', plain: true, dimColor: true, label: 'Later', - onPress: isDemo ? () => demoPress($, band, 'later') : () => later($, band), - }), - ] - } - const waiting = (ask: Proposed) => - Box({ key: 'rule-waiting', gap: 2, children: [Text({ dimColor: true, children: 'new rule waiting:' }), name(ask, 40), ...controlsFor(ask)] }) + const signedOut = isSignedOut ? [Box({ key: 'signed-out', children: Text({ dimColor: true, children: SIGNED_OUT_ROW }) })] : [] if (e.surface === 'desktop') { const buf = compose(band.current, build, titleOf(band.animal), band.layout) @@ -530,8 +673,9 @@ export function register(on: On, ctx: Ctx) { }), ], }) - if (!ask || at === null) return Box({ justifyContent: 'flex-end', children: art }) - return Box({ flexDirection: 'column', alignItems: 'flex-end', children: [art, waiting(ask)] }) + const under = [...(ask && at !== null ? [waiting(ask)] : []), ...signedOut] + if (under.length === 0) return Box({ justifyContent: 'flex-end', children: art }) + return Box({ flexDirection: 'column', alignItems: 'flex-end', children: [art, ...under] }) } band.lastCells = cellsOf(band) ?? '' @@ -559,30 +703,17 @@ export function register(on: On, ctx: Ctx) { }), })] : [] - if (!ask || !at) { - return Box({ justifyContent: 'flex-end', children: Box({ children: [raster, clicks, ...linked] }) }) - } - if (at !== 'below') { - return Box({ - justifyContent: 'flex-end', - children: Box({ - children: [ - raster, - clicks, - ...linked, - Box({ - position: 'absolute', top: at.row, left: at.col, width: at.width, - backgroundColor: BUB_BG_HEX, gap: 2, children: controlsFor(ask), - }), - ], - }), - }) - } - return Box({ - flexDirection: 'column', - alignItems: 'flex-end', - children: [Box({ children: [raster, clicks, ...linked] }), waiting(ask)], - }) + // the buttons inside the bubble, once its text is typed out (see buttonsAt) + const inBubble = ask && at && at !== 'below' + ? [Box({ + position: 'absolute', top: at.row, left: at.col, width: at.width, + backgroundColor: BUB_BG_HEX, gap: 2, children: controlsFor(ask), + })] + : [] + const drawn = Box({ children: [raster, clicks, ...linked, ...inBubble] }) + const under = [...(ask && at === 'below' ? [waiting(ask)] : []), ...signedOut] + if (under.length === 0) return Box({ justifyContent: 'flex-end', children: drawn }) + return Box({ flexDirection: 'column', alignItems: 'flex-end', children: [drawn, ...under] }) }) } @@ -599,7 +730,7 @@ async function feedFromState($: EngineInterface, band: Band) { const isFirstLook = band.seenFires === null band.seenFires = seen // the first look of this load takes what is there as already said - if (!isFirstLook && band.isEnabled) announce(band, fresh.map(saidOfNote)) + if (!isFirstLook) announce(band, fresh.map(saidOfNote)) const notes = proposalsOf(await readSafe(() => read($, PROPOSALS))) if (notes !== null && proposalsKey(notes) !== band.proposalsKey) { band.proposalsKey = proposalsKey(notes) @@ -627,14 +758,18 @@ async function lanesNow($: EngineInterface) { * that could not ask changes nothing. */ async function pollProposals($: EngineInterface, band: Band) { - if (band.isPolling || !band.isEnabled) return + if (band.isPolling) return // with the animal off the strip asks and answers, so the poll runs either way band.isPolling = true try { const session = await sessionIdOf($) - if (!session) return const since = await $.clock.now().catch(() => 0) + if (!session) return const listed = await listProposed($, band, session, since) - if (!listed) return + // a key the server refuses twice is no sign-in, whatever api-info says; no + // key is what the lookup the list just made said; a list that failed any + // other way says nothing of the sign-in + noteSignIn($, band, session, listed === 'refused' ? 'out' : listed === 'no_key' ? signInOf(band) : listed ? 'in' : 'unknown') + if (!listed || listed === 'refused' || listed === 'no_key') return band.env = envName(band.ctx.env) const merged = await update($, PROPOSALS, current => mergedProposals(proposalsOf(current) ?? [], listed.notes, since)) .then(proposalsOf, () => null) @@ -649,6 +784,29 @@ async function pollProposals($: EngineInterface, band: Band) { } } +/** + * What the poll found of the sign-in: no one signed in is said once a + * session and keeps its row under the animal; a sign-in after that is said + * too and takes the row away. 'unknown' (api-info could not answer) is + * neither, so a broken lookup never tells a signed-in person to log in. + */ +function noteSignIn($: EngineInterface, band: Band, session: string, now: SignIn) { + if (now === 'unknown') return + const was = band.signIn + // the strip keeps the answer too (its not-signed-in line, and the enable + // ask, which waits for a sign-in) but has no bubble to say it in + band.signIn = now + if (band.isEnabled) { + if (now === 'out' && band.saidSignedOutIn !== session) { + band.saidSignedOutIn = session + announce(band, [{ text: SIGNED_OUT_SAID, tone: 'advice' }]) + } else if (now === 'in' && was === 'out') { + announce(band, [{ text: SIGNED_IN_SAID, tone: 'advice' }]) + } + } + if (now !== was) $.ui.invalidate('ui.render') +} + /** Hand `notes` to the band after any update still running. */ function syncProposals($: EngineInterface, band: Band, notes: readonly ProposalNote[]): Promise { band.syncing = band.syncing.then(() => applyProposals($, band, notes)).catch(() => undefined) @@ -671,7 +829,6 @@ async function applyProposals($: EngineInterface, band: Band, notes: readonly Pr band.queue = band.queue.filter(q => !(q.tone === 'proposed' && unsaid.has(q.text))) $.ui.invalidate('ui.render') } - if (!band.isEnabled) return const session = await sessionIdOf($) const stored = announcedOf(await $.store.get(STORE_ANNOUNCED).catch(() => undefined)) if (session) for (const id of announcedIn(stored, session)) band.announced.add(id) @@ -704,6 +861,15 @@ async function apiFor(band: Band): Promise { return band.ctx.api().then(a => (isApi(a) ? a : undefined), () => undefined) } +/** What the last credential lookup said of the sign-in; 'unknown' for a ctx that cannot say. */ +function signInOf(band: Band): SignIn { + try { + return band.ctx.signIn() + } catch { + return 'unknown' + } +} + /** Drop the cached credential (after a 401) so the next apiFor() resolves it again. */ function forgetApi(band: Band) { try { @@ -751,11 +917,15 @@ async function send($: EngineInterface, band: Band, path: string, body?: { metho /** * This session's rules still `proposed` as the server lists them, and the - * base that answered; null when it could not be asked or answered oddly — - * then nothing may be taken off the band. + * base that answered; 'no_key' when there was no key to send; 'refused' + * when the key was refused even freshly resolved (a second 401); null when it could not be asked or answered oddly + * — then nothing may be taken off the band. */ -async function listProposed($: EngineInterface, band: Band, session: string, at: number): Promise<{ notes: ProposalNote[]; base: string } | null> { +async function listProposed($: EngineInterface, band: Band, session: string, at: number): Promise<{ notes: ProposalNote[]; base: string } | 'refused' | 'no_key' | null> { const sent = await send($, band, PROPOSED_PATH) + if (sent === 'no_key') return 'no_key' + // a second 401, with a freshly resolved key: the key itself is refused + if (sent && sent !== 'no_key' && sent.status === 401) return 'refused' if (!sent || sent === 'no_key' || sent.status < 200 || sent.status >= 300) return null const notes = listedRules(sent.text, session, at) return notes ? { notes, base: sent.base } : null @@ -794,14 +964,15 @@ async function forget($: EngineInterface, band: Band, ruleId: string) { async function setUp($: EngineInterface, band: Band) { if (band.isSetUp) return band.isSetUp = true - band.isEnabled = (await $.store.get(STORE_ENABLED).catch(() => undefined)) !== false + band.isEnabled = await wantedOf($, band) const savedScale = await $.store.get(STORE_SCALE).catch(() => undefined) band.scale = SCALES.includes(savedScale as never) ? (savedScale as number) : null band.sessionId = await sessionIdOf($) - // The engine has already drawn the band by now — for the fallback animal, - // at no saved scale, enabled — and nothing redraws it on its own: switch - // the art as well as the name, or the fallback walks in and stays until - // the terminal is next resized (a tmux split was how it showed). + // The engine has already drawn the band by now — hidden, since nothing is + // shown before this decides — and nothing redraws it on its own: switch + // the art as well as the name, then invalidate below, or the fallback + // walks in and stays until the terminal is next resized (a tmux split was + // how it showed). const animal = await pickFor($, band.sessionId) if (animal !== band.animal || buildFor(animal, band.scale) !== band.build) { switchTo(band, animal) @@ -827,6 +998,128 @@ async function setUp($: EngineInterface, band: Band) { void pollProposals($, band) } +/** + * Whether the companion shows in this session: the person turned it on + * (`/`, stored true), or the plugin's `companion` option is on and + * nobody turned it off (`/ off`, stored false) — and MemHub is + * active in the session's repository (`activeOf`). With the option off and + * nothing stored, which is how the plugin installs, it stays away. + */ +async function wantedOf($: EngineInterface, band: Band): Promise { + const stored = await $.store.get(STORE_ENABLED).catch(() => undefined) + const wanted = stored === true || (band.optionOn && stored !== false) + band.active = await activeOf($, band) + return wanted && band.active.enabled +} + +/** + * The strip's quiet line, when nothing fired and nothing waits: where MemHub + * is inactive (the allowlist) — this repository not listed, or no repository + * here at all — how to enable it; else the book the mod holds for this + * repository, or that the session is being captured. + */ +function quietOf($: EngineInterface, band: Band): string { + if (stripSignIn(band) === 'out') return `MemHub · ${SIGNED_OUT_ROW}` + if (band.active && !band.active.enabled) { + return band.active.repo + ? `MemHub · inactive in this repo (/${$.plugin.name}:repos add .)` + : `MemHub · inactive here: not in a repository (/${$.plugin.name}:repos add . inside one)` + } + const book = band.ctx.book() + if (!book) return 'MemHub · capturing' + if (!book.rules) return `MemHub · capturing · no rules for ${book.repo}` + return `MemHub · ${book.rules} rule${book.rules === 1 ? '' : 's'} armed for ${book.repo}` +} + +/** + * The sign-in as the strip knows it: what the poll last found, else what the + * credential lookup itself said (ctx.signIn: 'in' with a key, even when the + * poll's request then failed). 'unknown' before either. + */ +function stripSignIn(band: Band): SignIn { + return band.signIn !== 'unknown' ? band.signIn : signInOf(band) +} + +/** + * The repository the strip asks to enable MemHub in (ENG-1253), or undefined + * for no ask. Every condition must hold, all from `paths` at boot: a + * repos.json exists (`restricted`), the session is inside a checkout (`repo` + * and `identity` known), the person is signed in, and the repository is + * neither listed (`enabled`) nor declined — and the person has not said + * Later this session. Not signed in: the quiet line's own row wins, and the + * ask waits for a sign-in. Nothing here reads repos.json. + */ +function enableAskOf(band: Band): string | undefined { + const a = band.active + if (!a || a.enabled || !a.restricted || a.declined || !a.repo || !a.identity) return undefined + if (band.enableAsk === 'later') return undefined + if (stripSignIn(band) !== 'in') return undefined + return a.identity +} + +/** + * The ask's Yes or No: `rulebook_mod_cli.py answer`, which lists (Yes: + * `repo_allow.allow`, what `/memhub:repos add .` writes) or declines (No: + * `repo_allow.decline`) the session's checkout. The buttons wait while it + * writes, so a second press cannot answer twice. The Python hooks read the + * file per call and follow at the next one; the mod flips its own state here: + * ctx.active() (so `/goose` and the shell see it) and the strip's line — after + * Yes the animal, where the option or the store asks for it, as in any listed + * repository; else the quiet line. A write that failed leaves the ask up. + */ +async function answerEnable($: EngineInterface, band: Band, say: 'yes' | 'no') { + const was = band.active + if (band.enableAsk !== 'open' || !was?.identity) return + band.enableAsk = 'answering' + $.ui.invalidate('ui.render') + let got: Record | undefined + try { + const cwd = band.cwd || (await $.session.cwd().catch(() => '')) || '.' + const argv = ['python3', `${rootOf($)}/${MOD_CLI}`, 'answer', '--env', band.ctx.env, '--cwd', cwd, '--say', say] + const run = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null) + const out = run && run.exitCode === 0 ? lastJson(run.stdout) : undefined + got = out && typeof out === 'object' ? (out as Record) : undefined + } finally { + band.enableAsk = 'open' + } + if (!got || typeof got.enabled !== 'boolean') { + $.ui.invalidate('ui.render') + return + } + const next: Active = { ...was, enabled: got.enabled, declined: got.declined === true } + band.active = next + band.ctx.setActive(next) + if (next.enabled && !band.isEnabled) { + const stored = await $.store.get(STORE_ENABLED).catch(() => undefined) + if (stored === true || (band.optionOn && stored !== false)) { + band.isEnabled = true + start($, band) + if (band.isSetUp) void pollProposals($, band) + } + } + $.ui.invalidate('ui.render') +} + +/** The ask's Later: hidden for the rest of this session, and nothing written. */ +function enableLater($: EngineInterface, band: Band) { + band.enableAsk = 'later' + $.ui.invalidate('ui.render') +} + +/** + * The repository allowlist's answer for the session, as the shell read it + * (mod/ctx.ts `active`). The shell asks Python once at boot; should that + * take longer than ACTIVE_WAIT_MS, the animal shows rather than waits. + */ +async function activeOf($: EngineInterface, band: Band): Promise { + const asIs: Active = { enabled: true, repo: '' } + try { + return await Promise.race([band.ctx.active(), $.clock.sleep(ACTIVE_WAIT_MS).then(() => asIs)]) + } catch { + return asIs + } +} + /** The most lines a bubble says where the band is drawn; the terminal's default when undefined. */ const linesOf = (band: Band) => (band.surface === 'desktop' ? DESKTOP_TEXT_LINES : undefined) @@ -1095,6 +1388,16 @@ function later($: EngineInterface, band: Band) { function announce(band: Band, said: readonly Said[]) { if (said.length === 0) return + if (!band.isEnabled) { + // The strip: no bubble to say it in. The latest fire stands on the line + // (a proposal is shown from `asks`, with its buttons, not said). + const fire = [...said].reverse().find(s => s.tone !== 'proposed') + if (fire) { + band.stripFire = fire + band.stripFireTurns = STRIP_FIRE_TURNS + } + return + } band.queue.push(...said) band.queue.splice(0, Math.max(0, band.queue.length - MAX_QUEUED)) nudge(band) diff --git a/plugins/memhub/companion/tests/ask.test.ts b/plugins/memhub/companion/tests/ask.test.ts new file mode 100644 index 00000000..3ec8b07f --- /dev/null +++ b/plugins/memhub/companion/tests/ask.test.ts @@ -0,0 +1,245 @@ +// The strip's enable ask (ENG-1253). In a repository the machine's allowlist +// (repos.json) leaves out, the status strip asks once, at session start, +// whether to enable MemHub there: +// +// MemHub · enable in ? 1 Yes 2 No 3 Later +// +// It shows only when ALL of these hold, read off `rulebook_mod_cli.py paths` +// at boot (nothing re-reads repos.json in TypeScript): a repos.json exists +// (`restricted`), the session is inside a checkout (`repo`, `identity`), the +// person is signed in, and the repository is neither listed (`enabled`) nor +// `declined`. Yes runs `rulebook_mod_cli.py answer --say yes` (what +// `/memhub:repos add .` writes) and the strip flips to the quiet line — or +// the animal, where the option asks for one; No runs `--say no`, which +// records the repository as declined, and the ask is gone for good; Later +// hides it for this session only, and writes nothing. + +import type { On } from 'claude-code' +import { describe, type Engine, expect, mock, test } from 'claude-code/testing' + +import type { FireNote } from '../../types' +import { listed, ran, restBeneath, stateBeneath, TURN_END } from './beneath' + +const PLUGIN = 'memhub-staging' +const SESSION = 'sess-ask-1' +const CWD = '/work/client-app' +const BAND = { hasSurvey: false, isWorking: false, maxRows: 24, bodyColumns: 120, scroll: { offset: 0, bodyRows: 24 }, view: {} } +const RUN = { origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 120 } } as const +const ASK_TEXT = 'MemHub · enable in ClientCo/app?' +const QUIET_INACTIVE = `MemHub · inactive in this repo (/${PLUGIN}:repos add .)` + +/** What `paths` answers in a checkout a repos.json leaves out: the ask's case. */ +const UNLISTED = { + book: '/home/tester/.config/memhub-plugin/rulebook/book/app-abc.json', repo: 'app', ledger: '/l', + enabled: false, restricted: true, declined: false, identity: 'ClientCo/app', +} +const LISTED = { ...UNLISTED, enabled: true } +const DECLINED = { ...UNLISTED, declined: true } +/** The same checkout on a machine with no repos.json: active, as always. */ +const NO_FILE = { ...UNLISTED, enabled: true, restricted: false } +/** A session in the parent folder of several checkouts, with a repos.json: dormant, nothing to list. */ +const OUTSIDE = { book: null, repo: '', ledger: '/l', enabled: false, restricted: true, declined: false, identity: '' } +/** An older CLI's answer: no `restricted`, `declined` or `identity` at all. */ +const OLDER_CLI = { book: UNLISTED.book, repo: 'app', ledger: '/l', enabled: false } + +type Beneath = { + store?: Record + paths?: Record + /** What `answer` prints (the exit code and the JSON line); none: exit 1 with nothing. */ + answer?: { exitCode: number; out?: Record } + /** The bearers api-info hands out, one per resolution ('' is signed out). */ + bearers?: string[] +} + +/** Lets every pending promise chain run (the fakes answer in microtasks). */ +async function settle(): Promise { + for (let i = 0; i < 200; i++) await Promise.resolve() +} + +async function started($: Engine, on: On, beneath: Beneath = {}) { + mock.clock(on) + const store: Record = { ...(beneath.store ?? {}) } + on('store.get', async (_, e) => ({ value: store[e.key] })) + on('store.set', async (_, e) => { + store[e.key] = JSON.parse(JSON.stringify(e.value)) + return { value: undefined } + }) + on('store.delete', async (_, e) => { + delete store[e.key] + return { value: undefined } + }) + on('store.keys', async () => ({ value: Object.keys(store) })) + mock.env(on, { HOME: '/home/tester' }) + on('session.id', async () => ({ value: SESSION })) + on('session.start', async (_, e) => ({ cwd: e.cwd })) + on('ui.render', { component: 'AbovePrompt' }, ($, e) => $.ui.resolve(e).Box({ key: 'engine-band' })) + on('classic.PostToolUse', async () => ({ additionalContext: [] })) + on('command.register', async (_, e) => ({ value: { command: e.name } })) + on('turn.complete', async () => ({ text: '' })) + const state = stateBeneath(on) + const rest = restBeneath(on, { list: listed(), ...(beneath.bearers ? { bearers: beneath.bearers } : {}) }) + /** Every `answer` the strip ran, as argv. */ + const answers: string[][] = [] + on('process.run', async (_, e) => { + const info = rest.apiInfo(e.argv) + if (info) return info + if (e.argv[2] === 'paths' && beneath.paths) return ran(0, JSON.stringify(beneath.paths) + '\n') + if (e.argv[2] === 'answer') { + answers.push([...e.argv]) + const a = beneath.answer ?? { exitCode: 1 } + return ran(a.exitCode, a.out ? JSON.stringify(a.out) + '\n' : '') + } + return ran(1) + }) + on('ui.blit', async () => ({ value: {} })) + await $.session.start({ cwd: CWD, surface: 'terminal', isInteractive: true }) + await settle() + const band = await $.ui.mount({ plugin: PLUGIN, surface: 'terminal', component: 'AbovePrompt', props: BAND }) + await settle() + await band.redraw() + const animal = async () => (await band.find({ type: 'Raster', key: 'companion' })) !== undefined + const strip = async () => (await band.find({ key: 'memhub-strip' }))?.text + const asking = async () => (await band.find({ type: 'Text', text: ASK_TEXT })) !== undefined + const button = async (key: string) => (await band.find({ type: 'Button', key })) !== undefined + /** Press one of the ask's buttons and let the answer land and redraw. */ + const press = async (key: string) => { + await band.press({ key }) + await settle() + await band.redraw() + } + const fire = async (note: FireNote) => { + state.write('fires', [note]) + await band.redraw() + } + return { band, store, answers, animal, strip, asking, button, press, fire } +} + +const YES = { exitCode: 0, out: { identity: 'ClientCo/app', enabled: true, declined: false, path: '/home/tester/.config/memhub-plugin/repos.json' } } +const NO = { exitCode: 0, out: { identity: 'ClientCo/app', enabled: false, declined: true, path: '/home/tester/.config/memhub-plugin/repos.json' } } + +describe('when the strip asks', () => { + test('an unlisted, undeclined checkout, signed in: the one row with 1 Yes / 2 No / 3 Later, and no quiet line', async ($, on) => { + const { animal, strip, asking, button } = await started($, on, { paths: UNLISTED }) + expect(await animal()).toBe(false) + expect(await asking(), 'the ask').toBe(true) + expect(await button('enable-yes'), '1 Yes').toBe(true) + expect(await button('enable-no'), '2 No').toBe(true) + expect(await button('enable-later'), '3 Later').toBe(true) + expect(await strip(), 'the quiet line stands aside').toBeUndefined() + }) + + test('never when the repository is listed', async ($, on) => { + const { asking, strip } = await started($, on, { paths: LISTED }) + expect(await asking()).toBe(false) + expect(await strip()).toBe('MemHub · capturing') + }) + + test('never when the person declined it before', async ($, on) => { + const { asking, strip } = await started($, on, { paths: DECLINED }) + expect(await asking()).toBe(false) + expect(await strip()).toBe(QUIET_INACTIVE) + }) + + test('never with no repos.json', async ($, on) => { + const { asking, strip } = await started($, on, { paths: NO_FILE }) + expect(await asking()).toBe(false) + expect(await strip()).toBe('MemHub · capturing') + }) + + test('never outside any checkout: there is no repository to list', async ($, on) => { + const { asking, strip } = await started($, on, { paths: OUTSIDE }) + expect(await asking()).toBe(false) + expect(await strip()).toBe(`MemHub · inactive here: not in a repository (/${PLUGIN}:repos add . inside one)`) + }) + + test('never off an older CLI that says nothing of restricted, declined or identity', async ($, on) => { + const { asking, strip } = await started($, on, { paths: OLDER_CLI }) + expect(await asking()).toBe(false) + expect(await strip()).toBe(QUIET_INACTIVE) + }) + + test('not signed in wins: the not-signed-in line, no ask — until a later poll finds the sign-in', async ($, on) => { + const { band, asking, strip } = await started($, on, { paths: UNLISTED, bearers: ['', 'mhk_now'] }) + expect(await asking()).toBe(false) + expect(await strip()).toBe('MemHub · not signed in · /memhub:login') + // /memhub:login ran meanwhile: the turn's end polls again, api-info now has a key + await $.turn.complete(TURN_END) + await settle() + await band.redraw() + expect(await asking(), 'asked once signed in').toBe(true) + expect(await strip()).toBeUndefined() + }) + + test('a fire stands ahead of the ask, and the ask is back once the fire has gone', async ($, on) => { + const { band, asking, strip, fire } = await started($, on, { paths: UNLISTED }) + await fire({ ruleId: 'b43d6914-4cb3-4a91-84ad-cadbeb6dcfe4', line: '📏 Rule fired: Keep tests fast', rule: 'Keep tests fast', isBlocked: false, at: 1_000 }) + expect(await strip()).toBe('📏 Rule fired: Keep tests fast') + expect(await asking()).toBe(false) + await $.turn.complete(TURN_END) + await $.turn.complete(TURN_END) + await band.redraw() + expect(await asking()).toBe(true) + }) +}) + +describe('what each key does', () => { + test('1 Yes lists the repository through the CLI, and the strip flips to the quiet line at once', async ($, on) => { + const { band, store, answers, asking, strip, animal, press } = await started($, on, { paths: UNLISTED, answer: YES }) + await press('enable-yes') + expect(answers).toHaveLength(1) + const argv = answers[0]! + expect(argv[0]).toBe('python3') + expect(argv[1]).toMatch(/\/scripts\/rulebook_mod_cli\.py$/) + expect(argv.slice(2)).toEqual(['answer', '--env', 'staging', '--cwd', CWD, '--say', 'yes']) + expect(await asking(), 'the ask is gone').toBe(false) + expect(await strip(), 'the quiet line is the active one').toBe('MemHub · capturing') + expect(await animal(), 'the option is off: still the strip').toBe(false) + // ctx.active() says enabled now: /goose shows the animal instead of saying MemHub is inactive here + const r = await $.command.run({ command: 'goose', args: '', ...RUN }) + expect('text' in r ? r.text : '').not.toMatch(/inactive/) + await band.redraw() + expect(await animal()).toBe(true) + expect(store['companion.enabled']).toBe(true) + }) + + test('1 Yes with the companion option on: the animal, as in any listed repository', { options: { companion: true } }, async ($, on) => { + const { animal, asking, press } = await started($, on, { paths: UNLISTED, answer: YES }) + expect(await animal(), 'inactive here: the strip, with the ask').toBe(false) + expect(await asking()).toBe(true) + await press('enable-yes') + expect(await asking()).toBe(false) + expect(await animal()).toBe(true) + }) + + test('2 No declines the repository through the CLI; the ask is gone and the quiet line says inactive', async ($, on) => { + const { answers, asking, strip, press } = await started($, on, { paths: UNLISTED, answer: NO }) + await press('enable-no') + expect(answers.map(a => a.slice(2))).toEqual([['answer', '--env', 'staging', '--cwd', CWD, '--say', 'no']]) + expect(await asking()).toBe(false) + expect(await strip()).toBe(QUIET_INACTIVE) + // and the repository is inactive still: /goose says why, and stores nothing + const r = await $.command.run({ command: 'goose', args: '', ...RUN }) + expect('text' in r ? r.text : '').toMatch(/inactive in this repository/) + }) + + test('3 Later hides the ask for this session and writes nothing', async ($, on) => { + const { band, answers, asking, strip, press } = await started($, on, { paths: UNLISTED }) + await press('enable-later') + expect(answers).toEqual([]) + expect(await asking()).toBe(false) + expect(await strip()).toBe(QUIET_INACTIVE) + // and stays hidden through the session's turns + await $.turn.complete(TURN_END) + await band.redraw() + expect(await asking()).toBe(false) + }) + + test('a write that failed leaves the ask up, so it can be tried again', async ($, on) => { + const { answers, asking, press } = await started($, on, { paths: UNLISTED, answer: { exitCode: 1 } }) + await press('enable-yes') + expect(answers).toHaveLength(1) + expect(await asking(), 'still asking').toBe(true) + await press('enable-yes') + expect(answers, 'pressed again: asked again').toHaveLength(2) + }) +}) diff --git a/plugins/memhub/companion/tests/beneath.ts b/plugins/memhub/companion/tests/beneath.ts index 4d6df35e..0c407a5f 100644 --- a/plugins/memhub/companion/tests/beneath.ts +++ b/plugins/memhub/companion/tests/beneath.ts @@ -34,7 +34,7 @@ export type Rest = { decide?: (Answer | 'throw')[] /** Held until the test resolves it: a PATCH still in flight. */ gate?: Promise - /** The bearers api-info hands out, one per resolution (the last repeats); '' is no key. */ + /** The bearers api-info hands out, one per resolution (the last repeats); '' is no key, '!' a lookup that fails. */ bearers?: string[] /** The Studio origin api-info hands out; STUDIO unless given ('' pairs none). */ studio?: string @@ -87,6 +87,7 @@ export function restBeneath(on: On, rest: Rest = {}) { const bearers = rest.bearers ?? [BEARER] const bearer = bearers[Math.min(resolutions, bearers.length - 1)] ?? '' resolutions += 1 + if (bearer === '!') return ran(1, `${JSON.stringify({ error: 'ModuleNotFoundError: no module' })}\n`) const answer = bearer ? { base: BASE, bearer, studio: rest.studio ?? STUDIO } : {} return ran(0, `${JSON.stringify(answer)}\n`) } diff --git a/plugins/memhub/companion/tests/click.test.ts b/plugins/memhub/companion/tests/click.test.ts index ffd5a9b0..76341243 100644 --- a/plugins/memhub/companion/tests/click.test.ts +++ b/plugins/memhub/companion/tests/click.test.ts @@ -73,7 +73,9 @@ type Beneath = { async function started($: Engine, on: On, beneath: Beneath = {}) { const clock = mock.clock(on) - const store: Record = { ...(beneath.store ?? {}) } + // `companion.enabled` true unless the test says otherwise: the person ran + // `/goose` once (the `companion` option is off by default; option.test.ts holds that) + const store: Record = { 'companion.enabled': true, ...(beneath.store ?? {}) } on('store.get', async (_, e) => ({ value: store[e.key] })) on('store.set', async (_, e) => { store[e.key] = JSON.parse(JSON.stringify(e.value)) @@ -277,11 +279,14 @@ describe('which animal a session shows', () => { expect(raster!.props.columns, 'still laid out at no scale').toBe(scaled.columns) }) - test('drawn before session.start, a companion turned off draws nothing, not an empty band', { timeoutMs: 60_000 }, async ($, on) => { + test('drawn before session.start, a companion turned off draws the status line, not the animal', { timeoutMs: 60_000 }, async ($, on) => { const x = await started($, on, { store: { 'companion.pin': 'goose', 'companion.enabled': false }, drawFirst: true }) await x.play(1_000) expect(await x.band.find({ type: 'Raster', key: 'companion' }), 'the pre-start band stayed').toBeUndefined() expect(await x.band.find({ type: 'Button', key: 'companion-pet' }), 'a dead ♥ stayed').toBeUndefined() + const strip = await x.band.find({ key: 'memhub-strip' }) + expect(strip, 'the strip stands in for the animal').toBeDefined() + expect(strip!.text).toMatch(/^MemHub · /) }) test('/ always pins it; / random unpins; never and include edit the pool', async ($, on) => { diff --git a/plugins/memhub/companion/tests/desktop.test.ts b/plugins/memhub/companion/tests/desktop.test.ts index e11b6907..b0388f43 100644 --- a/plugins/memhub/companion/tests/desktop.test.ts +++ b/plugins/memhub/companion/tests/desktop.test.ts @@ -31,7 +31,8 @@ const BAND = { */ async function started($: Engine, on: On, list = listed(), onRegister?: (name: string) => void) { const clock = mock.clock(on) - const store: Record = { 'companion.pin': 'goose' } + // `companion.enabled` true: the person ran `/goose` once (the `companion` option is off by default) + const store: Record = { 'companion.pin': 'goose', 'companion.enabled': true } on('store.get', async (_, e) => ({ value: store[e.key] })) on('store.set', async (_, e) => { store[e.key] = JSON.parse(JSON.stringify(e.value)) diff --git a/plugins/memhub/companion/tests/fires.test.ts b/plugins/memhub/companion/tests/fires.test.ts index 9178d706..f641214a 100644 --- a/plugins/memhub/companion/tests/fires.test.ts +++ b/plugins/memhub/companion/tests/fires.test.ts @@ -41,7 +41,8 @@ type Beneath = { state?: Record; context?: string[] } async function started($: Engine, on: On, beneath: Beneath = {}) { const clock = mock.clock(on) - const store: Record = { 'companion.pin': 'goose' } + // `companion.enabled` true: the person ran `/goose` once (the `companion` option is off by default) + const store: Record = { 'companion.pin': 'goose', 'companion.enabled': true } on('store.get', async (_, e) => ({ value: store[e.key] })) on('store.set', async (_, e) => { store[e.key] = JSON.parse(JSON.stringify(e.value)) diff --git a/plugins/memhub/companion/tests/option.test.ts b/plugins/memhub/companion/tests/option.test.ts new file mode 100644 index 00000000..d0e8c2b0 --- /dev/null +++ b/plugins/memhub/companion/tests/option.test.ts @@ -0,0 +1,202 @@ +// Whether the ANIMAL shows, and what stands in its place when it does not: +// the plugin's `companion` option (off by default), the `/` command +// (stored `companion.enabled`), and the machine's repository allowlist +// (repos.json, read by the shell at boot as `paths`' `enabled` and handed on +// as ctx.active()). With the animal off the band is never empty: one plain +// status line (the strip) stands there, quiet and unmoving, and still shows a +// fire or a proposed rule with its buttons. +// +// option off, nothing stored → the strip, not the animal (how the plugin installs) +// option off, /goose → the animal, and stored for the next session +// option on, nothing stored → the animal +// option on, /goose off → the strip (the store wins) +// repository not listed → the strip says MemHub is inactive here; /goose says why +// strip + a fire → "📏 Rule fired: …" on the line, for two turn ends +// strip + a proposed rule → its name and 1 Activate / 2 Reject / 3 Later + +import type { On } from 'claude-code' +import { describe, type Engine, expect, mock, test } from 'claude-code/testing' + +import type { FireNote } from '../../types' +import { listed, ran, restBeneath, ruleRow, stateBeneath, TURN_END } from './beneath' + +const PLUGIN = 'memhub-staging' +const SESSION = 'sess-option-1' +const RULE_ID = 'b43d6914-4cb3-4a91-84ad-cadbeb6dcfe4' +const BAND = { hasSurvey: false, isWorking: false, maxRows: 24, bodyColumns: 120, scroll: { offset: 0, bodyRows: 24 }, view: {} } +const RUN = { origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 120 } } as const + +type Beneath = { + store?: Record + /** What `rulebook_mod_cli.py paths` answers for the session's cwd; none: the command fails (no book). */ + paths?: Record + /** The server's proposed-rules list; none lists nothing. */ + listed?: string +} + +/** Lets every pending promise chain run (the fakes answer in microtasks). */ +async function settle(): Promise { + for (let i = 0; i < 200; i++) await Promise.resolve() +} + +async function started($: Engine, on: On, beneath: Beneath = {}) { + mock.clock(on) + const store: Record = { ...(beneath.store ?? {}) } + on('store.get', async (_, e) => ({ value: store[e.key] })) + on('store.set', async (_, e) => { + store[e.key] = JSON.parse(JSON.stringify(e.value)) + return { value: undefined } + }) + on('store.delete', async (_, e) => { + delete store[e.key] + return { value: undefined } + }) + on('store.keys', async () => ({ value: Object.keys(store) })) + mock.env(on, { HOME: '/home/tester' }) + on('session.id', async () => ({ value: SESSION })) + on('session.start', async (_, e) => ({ cwd: e.cwd })) + on('ui.render', { component: 'AbovePrompt' }, ($, e) => $.ui.resolve(e).Box({ key: 'engine-band' })) + on('classic.PostToolUse', async () => ({ additionalContext: [] })) + on('command.register', async (_, e) => ({ value: { command: e.name } })) + on('turn.complete', async () => ({ text: '' })) + const state = stateBeneath(on) + const rest = restBeneath(on, { list: beneath.listed ?? listed() }) + on('process.run', async (_, e) => { + const info = rest.apiInfo(e.argv) + if (info) return info + if (e.argv[2] === 'paths' && beneath.paths) return ran(0, JSON.stringify(beneath.paths) + '\n') + return ran(1) + }) + on('ui.blit', async () => ({ value: {} })) + await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true }) + await settle() + const band = await $.ui.mount({ plugin: PLUGIN, surface: 'terminal', component: 'AbovePrompt', props: BAND }) + const animal = async () => (await band.find({ type: 'Raster', key: 'companion' })) !== undefined + const strip = async () => (await band.find({ key: 'memhub-strip' }))?.text + /** Another part of the mod writes a fire; the host draws the band that read it again. */ + const fire = async (note: FireNote) => { + state.write('fires', [note]) + await band.redraw() + } + return { band, store, state, animal, strip, fire } +} + +const DORMANT = { book: '/home/tester/.config/memhub-plugin/rulebook/book/repo-abc.json', repo: 'client/app', ledger: '/l', enabled: false, restricted: true } +const LISTED = { ...DORMANT, enabled: true } +const NOTE: FireNote = { ruleId: RULE_ID, line: '📏 Rule fired: Keep tests fast', rule: 'Keep tests fast', isBlocked: false, at: 1_000 } + +describe('the companion option, off by default', () => { + test('nothing stored: the status line, not the animal', async ($, on) => { + const { animal, strip } = await started($, on) + expect(await animal()).toBe(false) + expect(await strip()).toBe('MemHub · capturing') + }) + + test('/goose shows the animal in this session and stores that, so the next session shows it too', async ($, on) => { + const { band, store, animal, strip } = await started($, on) + const r = await $.command.run({ command: 'goose', args: '', ...RUN }) + expect('text' in r ? r.text : '').not.toMatch(/inactive/) + await band.redraw() + expect(await animal()).toBe(true) + expect(await strip()).toBeUndefined() + expect(store['companion.enabled']).toBe(true) + }) + + test('stored from an earlier /goose: the animal, without the option', async ($, on) => { + const { animal } = await started($, on, { store: { 'companion.enabled': true } }) + expect(await animal()).toBe(true) + }) +}) + +describe('the companion option, on', () => { + test('nothing stored: the animal', { options: { companion: true } }, async ($, on) => { + const { animal, strip } = await started($, on) + expect(await animal()).toBe(true) + expect(await strip()).toBeUndefined() + }) + + test('/goose off was run: the status line, the store wins', { options: { companion: true } }, async ($, on) => { + const { animal, strip } = await started($, on, { store: { 'companion.enabled': false } }) + expect(await animal()).toBe(false) + expect(await strip()).toBe('MemHub · capturing') + }) + + test('/goose off mid-session returns to the status line and says so', { options: { companion: true } }, async ($, on) => { + const { band, animal, strip } = await started($, on) + expect(await animal()).toBe(true) + const r = await $.command.run({ command: 'goose', args: 'off', ...RUN }) + expect('text' in r ? r.text : '').toMatch(/status line stays/) + await band.redraw() + expect(await animal()).toBe(false) + expect(await strip()).toBe('MemHub · capturing') + }) +}) + +describe('a repository the allowlist leaves out', () => { + test('the status line says MemHub is inactive here, and /goose says why instead of showing the animal', { options: { companion: true } }, async ($, on) => { + const { band, store, animal, strip } = await started($, on, { paths: DORMANT }) + expect(await animal()).toBe(false) + expect(await strip()).toBe(`MemHub · inactive in this repo (/${PLUGIN}:repos add .)`) + const r = await $.command.run({ command: 'goose', args: '', ...RUN }) + expect('text' in r ? r.text : '').toMatch(/inactive in this repository/) + expect('text' in r ? r.text : '').toContain(`/${PLUGIN}:repos add .`) + await band.redraw() + expect(await animal()).toBe(false) + expect(store['companion.enabled']).toBeUndefined() + }) + + test('a listed repository: the animal as usual', { options: { companion: true } }, async ($, on) => { + const { animal } = await started($, on, { paths: LISTED }) + expect(await animal()).toBe(true) + }) + + test('a session outside any checkout (the parent folder of several repos): dormant, and the strip says why', { options: { companion: true } }, async ($, on) => { + // `paths` names no repo and no book, and the allowlist says dormant (repo_allow.session_enabled); + // before this the answer was dropped with the missing book and the strip said "capturing" + const { band, animal, strip } = await started($, on, { paths: { book: null, repo: '', ledger: '/l', enabled: false, restricted: true } }) + expect(await animal()).toBe(false) + expect(await strip()).toBe(`MemHub · inactive here: not in a repository (/${PLUGIN}:repos add . inside one)`) + const r = await $.command.run({ command: 'goose', args: '', ...RUN }) + expect('text' in r ? r.text : '').toMatch(/not inside a repository/) + await band.redraw() + expect(await animal()).toBe(false) + }) + + test('a session outside any checkout with NO repos.json: active, as always', { options: { companion: true } }, async ($, on) => { + const { animal } = await started($, on, { paths: { book: null, repo: '', ledger: '/l', enabled: true, restricted: false } }) + expect(await animal()).toBe(true) + }) +}) + +describe('the status line still shows what MemHub does', () => { + test('a fire stands on the line, as the animal would say it, through the turn and the next', async ($, on) => { + const { band, strip, fire } = await started($, on) + await fire(NOTE) + expect(await strip()).toBe('📏 Rule fired: Keep tests fast') + await $.turn.complete(TURN_END) + await band.redraw() + expect(await strip(), 'still there after the turn it fired in').toBe('📏 Rule fired: Keep tests fast') + await $.turn.complete(TURN_END) + await band.redraw() + expect(await strip(), 'gone after the next').toBe('MemHub · capturing') + }) + + test('a blocking fire is said as blocked', async ($, on) => { + const { strip, fire } = await started($, on) + await fire({ ...NOTE, line: '⛔️ Rule fired: No force-push', rule: 'No force-push', isBlocked: true }) + expect(await strip()).toBe('⛔️ Blocked: No force-push') + }) + + test('a proposed rule shows its name and the 1 Activate / 2 Reject / 3 Later buttons, with no animal', async ($, on) => { + const { band, animal } = await started($, on, { listed: listed(ruleRow(SESSION, RULE_ID, 'Run only the touched test suites')) }) + await settle() + await band.redraw() + expect(await animal()).toBe(false) + expect(await band.find({ type: 'Button', key: 'rule-activate' }), '1 Activate').toBeDefined() + expect(await band.find({ type: 'Button', key: 'rule-reject' }), '2 Reject').toBeDefined() + expect(await band.find({ type: 'Button', key: 'rule-later' }), '3 Later').toBeDefined() + const name = await band.find({ type: 'Button', key: 'rule-name' }) + expect(name, "the rule's name is the link to Studio").toBeDefined() + expect(String(name!.props.label)).toMatch(/touched test suites/) + }) +}) diff --git a/plugins/memhub/companion/tests/stop_announce.test.ts b/plugins/memhub/companion/tests/stop_announce.test.ts index e7d3413f..3a75b0b3 100644 --- a/plugins/memhub/companion/tests/stop_announce.test.ts +++ b/plugins/memhub/companion/tests/stop_announce.test.ts @@ -70,8 +70,9 @@ async function started($: Engine, on: On, beneath: Beneath = {}) { async function startedBare($: Engine, on: On, beneath: Beneath = {}) { const clock = mock.clock(on) // the plugin's store, in memory and read back by the test; `store` seeds it, - // as a module reload finds what an earlier load wrote - const store: Record = { ...(beneath.store ?? {}) } + // as a module reload finds what an earlier load wrote. `companion.enabled` + // true: the person ran `/goose` once (the `companion` option is off by default) + const store: Record = { 'companion.enabled': true, ...(beneath.store ?? {}) } on('store.get', async (_, e) => ({ value: store[e.key] })) on('store.set', async (_, e) => { store[e.key] = JSON.parse(JSON.stringify(e.value)) @@ -204,7 +205,7 @@ describe('a rule the harness filed, found by the poll', () => { expect(frames.length).toBeGreaterThan(10) }) - test('signed out: no key, no request, nothing said', { timeoutMs: 60_000 }, async ($, on) => { + test('signed out: no key, no request, no proposal said', { timeoutMs: 60_000 }, async ($, on) => { const { clock, frames, rest } = await started($, on, { list: FILED, bearers: [''] }) await turnEnds($) for (let t = 0; t < 5_000; t += STEP_MS) await clock.advance(STEP_MS) @@ -213,6 +214,88 @@ describe('a rule the harness filed, found by the poll', () => { }) }) +// No one signed in to MemHub: api-info answers `{}`, or the server refuses +// the key even freshly resolved. The animal says so once a session and keeps +// a row under it until a poll finds a sign-in, which it says too. A lookup +// that cannot answer at all is not a signed-out person: it says nothing. + +const SIGNED_OUT_ROW = 'not signed in · /memhub:login' +/** The end of the not-signed-in bubble, so a frame holding it holds all of it. */ +const SIGNED_OUT_END = 'Run /memhub:login' + +describe('not signed in to MemHub', () => { + const hasRow = async (band: { find: (q: object) => Promise }) => + (await band.find({ type: 'Text', text: SIGNED_OUT_ROW })) !== undefined + + test('signed out: the animal says so once, and the row stays under it', { timeoutMs: 120_000 }, async ($, on) => { + const { band, clock, frames } = await started($, on, { bearers: [''] }) + for (let t = 0; t < PLAY_MS && !frames.some(f => f.includes(SIGNED_OUT_END)); t += STEP_MS) await clock.advance(STEP_MS) + const said = frames.find(f => f.includes(SIGNED_OUT_END)) + expect(said, 'no frame ever said it is not signed in').toBeDefined() + expect(said).toContain('Not signed in to MemHub') + expect(await hasRow(band), 'no row under the animal').toBe(true) + // play on until the bubble has gone; later polls find it signed out still: + // the row stays, nothing is said again + const isGone = () => !frames.at(-1)!.includes('Not signed in') + for (let t = 0; t < PLAY_MS && !isGone(); t += STEP_MS) await clock.advance(STEP_MS) + expect(isGone(), 'the bubble never went').toBe(true) + const firstGone = frames.length + await turnEnds($) + await clock.advance(300_000) + for (let t = 0; t < PLAY_MS; t += STEP_MS) await clock.advance(STEP_MS) + expect(frames.slice(firstGone).some(f => f.includes('Not signed in')), 'said a second time').toBe(false) + expect(await hasRow(band)).toBe(true) + }) + + test('a key the server refuses even freshly resolved is no sign-in', { timeoutMs: 60_000 }, async ($, on) => { + const { band, clock, frames, rest } = await started($, on, { list: { status: 401, text: '{}' } }) + for (let t = 0; t < PLAY_MS && !frames.some(f => f.includes(SIGNED_OUT_END)); t += STEP_MS) await clock.advance(STEP_MS) + expect(rest.gets, 'asked twice: once more with a fresh key').toHaveLength(2) + expect(frames.some(f => f.includes('Not signed in to MemHub')), 'it never said so').toBe(true) + expect(await hasRow(band)).toBe(true) + }) + + test('a refused key, then a poll that fails another way: still not signed in, nothing more said', { timeoutMs: 120_000 }, async ($, on) => { + let answer: { status: number; text: string } | 'throw' = { status: 401, text: '{}' } + const { band, clock, frames } = await started($, on, { list: () => answer }) + for (let t = 0; t < PLAY_MS && !frames.some(f => f.includes(SIGNED_OUT_END)); t += STEP_MS) await clock.advance(STEP_MS) + expect(await hasRow(band)).toBe(true) + for (const next of [{ status: 503, text: '{}' }, 'throw'] as const) { + answer = next + await turnEnds($) + for (let t = 0; t < 10_000; t += STEP_MS) await clock.advance(STEP_MS) + expect(frames.some(f => f.includes('Signed in to MemHub')), `a ${JSON.stringify(next)} said signed in`).toBe(false) + expect(await hasRow(band), `a ${JSON.stringify(next)} took the row away`).toBe(true) + } + }) + + test('a lookup that cannot answer says nothing of the sign-in', { timeoutMs: 60_000 }, async ($, on) => { + const { band, clock, frames } = await started($, on, { bearers: ['!'] }) + await turnEnds($) + for (let t = 0; t < 10_000; t += STEP_MS) await clock.advance(STEP_MS) + expect(frames.some(f => f.includes('signed in'))).toBe(false) + expect(await hasRow(band)).toBe(false) + }) + + test('signed in: no row, nothing said of it', { timeoutMs: 60_000 }, async ($, on) => { + const { band, clock, frames } = await started($, on) + for (let t = 0; t < 10_000; t += STEP_MS) await clock.advance(STEP_MS) + expect(frames.some(f => /signed in/i.test(f))).toBe(false) + expect(await hasRow(band)).toBe(false) + }) + + test('a sign-in mid-session is said, and the row goes', { timeoutMs: 120_000 }, async ($, on) => { + const { band, clock, frames } = await started($, on, { bearers: ['', BEARER] }) + for (let t = 0; t < PLAY_MS && !frames.some(f => f.includes(SIGNED_OUT_END)); t += STEP_MS) await clock.advance(STEP_MS) + expect(await hasRow(band)).toBe(true) + // /memhub:login ran; the next turn's end looks again + await turnEnds($) + for (let t = 0; t < PLAY_MS && !frames.some(f => f.includes('Signed in to MemHub')); t += STEP_MS) await clock.advance(STEP_MS) + expect(frames.some(f => f.includes('Signed in to MemHub')), 'it never said the sign-in').toBe(true) + expect(await hasRow(band)).toBe(false) + }) +}) + // A waiting proposal is answered on three plain buttons (1: Activate, 2: // Reject, 3: Later). While the goose presents it they sit INSIDE its bubble, // on the footer row other bubbles give to "Got it", once the text is typed diff --git a/plugins/memhub/hooks/claude-hooks.json b/plugins/memhub/hooks/claude-hooks.json index 46ffe1f7..853453a3 100644 --- a/plugins/memhub/hooks/claude-hooks.json +++ b/plugins/memhub/hooks/claude-hooks.json @@ -12,16 +12,6 @@ } ] }, - { - "matcher": "^mcp__.+__add_memory$", - "hooks": [ - { - "type": "command", - "timeout": 5, - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_entry.py\" PreToolUse add_memory_gate" - } - ] - }, { "matcher": "^mcp__.+__create_rule$", "hooks": [ @@ -63,7 +53,7 @@ { "type": "command", "timeout": 30, - "statusMessage": "MemHub: PR created — arming babysit loop", + "statusMessage": "MemHub: PR created \u2014 arming babysit loop", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_entry.py\" PostToolUse pr_babysit_trigger" } ] @@ -101,6 +91,19 @@ ] } ], + "PostToolUseFailure": [ + { + "matcher": "^(Bash|Edit|MultiEdit|Write|NotebookEdit|Read)$", + "hooks": [ + { + "type": "command", + "timeout": 5, + "statusMessage": "MemHub: checking team rules", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_entry.py\" PostToolUseFailure rulebook_hook" + } + ] + } + ], "SessionStart": [ { "hooks": [ @@ -136,6 +139,17 @@ ] } ], + "UserPromptExpansion": [ + { + "hooks": [ + { + "type": "command", + "timeout": 5, + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hook_entry.py\" UserPromptExpansion rulebook_hook" + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/plugins/memhub/hooks/cursor-hooks.json b/plugins/memhub/hooks/cursor-hooks.json index b39eda6e..6864ec2e 100644 --- a/plugins/memhub/hooks/cursor-hooks.json +++ b/plugins/memhub/hooks/cursor-hooks.json @@ -1,6 +1,11 @@ { "version": 1, "hooks": { + "sessionStart": [ + { + "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/cursor_capture.cmd\" sessionStart" + } + ], "beforeShellExecution": [ { "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/cursor_capture.cmd\" beforeShellExecution" @@ -35,6 +40,23 @@ { "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/cursor_capture.cmd\" sessionEnd" } + ], + "preToolUse": [ + { + "matcher": "Read|Task", + "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/cursor_capture.cmd\" preToolUse" + } + ], + "postToolUse": [ + { + "matcher": "Task", + "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/cursor_capture.cmd\" postToolUse" + } + ], + "beforeReadFile": [ + { + "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/cursor_capture.cmd\" beforeReadFile" + } ] } } diff --git a/plugins/memhub/hooks/hooks.json b/plugins/memhub/hooks/hooks.json index ec85e1e7..9e79b80c 100644 --- a/plugins/memhub/hooks/hooks.json +++ b/plugins/memhub/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "MemHub's Claude Code mod: the team Rulebook's lanes in-process (rules checked on each tool call, prompt and session, claimed from the Python command hooks only once the rule book is loaded, and handed back to them on any failure) and the companion above the prompt. The MemHub companion above the prompt: one animal a session (/goose, /hippo, /penguin, /shiba, and any other in animals/), picked from the session id unless one is pinned (`/ always`; `random`, `never` and `include` shape the pick), asleep while idle, looking around while Claude answers or you type, up announcing each MemHub rule that fires — crossly when one blocks a call — and, whichever animal it is, hopping up with a new rule the harness proposed, asking Activate / Reject / Later on buttons inside its bubble (1, 2, 3). Clicking the ♥ beside it pets it, and a proposal's rule↗ opens that rule in MemHub Studio.", + "description": "MemHub's Claude Code mod: the team Rulebook's lanes in-process (rules checked on each tool call, prompt and session, claimed from the Python command hooks only once the rule book is loaded, and handed back to them on any failure) and the companion above the prompt. The MemHub companion above the prompt: one animal a session (/goose, /hippo, /penguin, /shiba, and any other in animals/), picked from the session id unless one is pinned (`/ always`; `random`, `never` and `include` shape the pick), asleep while idle, looking around while Claude answers or you type, up announcing each MemHub rule that fires — crossly when one blocks a call — and, whichever animal it is, hopping up with a new rule the harness proposed, asking Activate / Reject / Later on buttons inside its bubble (1, 2, 3). Signed out of MemHub, it says so once a session and keeps a /memhub:login row under it until you sign in. Clicking the ♥ beside it pets it, and a proposal's rule↗ opens that rule in MemHub Studio.", "modules": [ "../mod/register.ts" ] diff --git a/plugins/memhub/mcp.json b/plugins/memhub/mcp.json index 00bc97ba..ab3df92a 100644 --- a/plugins/memhub/mcp.json +++ b/plugins/memhub/mcp.json @@ -5,7 +5,7 @@ "type": "streamable-http", "url": "https://api.memhub.xtrace.ai/mcp-server/mcp", "headers": { - "X-MemHub-Plugin-Version": "0.124.0" + "X-MemHub-Plugin-Version": "0.133.0" } } } diff --git a/plugins/memhub/mod/book.ts b/plugins/memhub/mod/book.ts index cda0864c..c8bab390 100644 --- a/plugins/memhub/mod/book.ts +++ b/plugins/memhub/mod/book.ts @@ -20,7 +20,9 @@ import { toHookRule as shapeRow, versionTuple } from './engine/rules' * for this build's own version so a `min_hook_version` row degrades exactly * as it does in the Python hook beside it. No forward-test claim here * (`activeBase` false): /memhub:create-rule's private-base forward test is - * served by the Python hook, which reads the claim. + * served by the Python hook, which reads the claim — the lanes hand it every + * call the claim covers (mod/redirect.ts), since the rows dropped here are + * exactly the ones that test must see fire. */ export const hookRuleFor = (hookVersion: string | undefined) => (row: Record): HookRule | null => shapeRow(row, { hookVersion: versionTuple(hookVersion ?? null), activeBase: false }) @@ -70,7 +72,12 @@ export function msOfIso(iso: string | null | undefined): number | undefined { } export type BookFile = { etag?: string | null; fetched_at?: string | null; rules: Record[] } -export type Paths = { book: string; repo: string; ledger?: string } +/** + * What `paths` answers for a checkout. `enabled` is the repository + * allowlist's verdict (scripts/repo_allow.py: `~/.config/memhub-plugin/repos.json`); + * false means MemHub is dormant there, and the mod claims nothing. + */ +export type Paths = { book: string; repo: string; ledger?: string; enabled: boolean } /** The cached book, as `load_book` accepts it: a dict whose `rules` is a list. */ export function parseBook(text: string): BookFile | undefined { @@ -120,18 +127,58 @@ export function isSecure(url: string): boolean { return m[1]!.toLowerCase() === 'http' && (host === 'localhost' || host === '127.0.0.1' || host === '::1') } -/** `rulebook_mod_cli.py paths --env --cwd `: the book file, the repo and the ledger dir. */ -export async function pathsFor(io: Io, ctx: Ctx, cwd: string): Promise { +/** + * What `paths` answers for a session's cwd, whole: the book to serve (none + * outside a checkout, or with no book), the repo's name ('' outside any + * checkout), and the allowlist's verdict — which stands even where there is + * no repo: a session in a folder that is no repository is dormant when a + * repos.json exists (repo_allow.session_enabled), and the companion says so. + * With it, what the companion's enable ask (ENG-1253) reads: `restricted` + * (a repos.json exists), `declined` (the person answered No here before) and + * `identity` (the checkout as repos.json names it, `org/name`; '' unknown). + */ +export type PathsAnswer = { + paths: Paths | undefined + repo: string + enabled: boolean + restricted: boolean + declined: boolean + identity: string + /** + * The install's own rulebook base (`real_base`), where a create-rule + * forward test leaves its claim file (mod/redirect.ts). `base` itself may + * already be that test's private copy when the claim covers the cwd. + * Undefined from an older CLI. + */ + realBase?: string +} + +/** `rulebook_mod_cli.py paths --env --cwd `, as the CLI answers it. */ +export async function askPaths(io: Io, ctx: Ctx, cwd: string): Promise { const r = await io.run(['python3', `${ctx.root}/${MOD_CLI}`, 'paths', '--env', ctx.env, '--cwd', cwd], { timeoutMs: 10_000 }) if (r.exitCode !== 0) throw new Error(`paths exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`) const got = lastJson(r.stdout) as Record | undefined if (!got || typeof got !== 'object') throw new Error('paths printed no JSON') const book = got.book ?? got.book_path - const repo = got.repo + const repo = typeof got.repo === 'string' ? got.repo : '' const ledger = got.ledger ?? got.ledger_dir + // An older CLI answers no `enabled`: active, as everything was before the list existed. + const enabled = got.enabled !== false + // …and no `restricted`, `declined` or `identity`: nothing to ask about, then. + const rest = { + restricted: got.restricted === true, + declined: got.declined === true, + identity: typeof got.identity === 'string' ? got.identity : '', + realBase: typeof got.real_base === 'string' && got.real_base ? got.real_base : undefined, + } // No repo (not a git checkout, or no remote): no book to serve, nothing to claim. - if (typeof repo !== 'string' || !repo || typeof book !== 'string' || !book) return undefined - return { book, repo, ledger: typeof ledger === 'string' ? ledger : undefined } + if (!repo || typeof book !== 'string' || !book) return { paths: undefined, repo, enabled, ...rest } + return { paths: { book, repo, ledger: typeof ledger === 'string' ? ledger : undefined, enabled }, repo, enabled, ...rest } +} + +/** `askPaths`' book, or undefined where there is none to serve. */ +export async function pathsFor(io: Io, ctx: Ctx, cwd: string): Promise { + return (await askPaths(io, ctx, cwd)).paths } export type RefreshOutcome = 'updated' | 'unchanged' | 'upgrade' | 'skipped' | 'failed' @@ -139,6 +186,8 @@ export type RefreshOutcome = 'updated' | 'unchanged' | 'upgrade' | 'skipped' | ' export class Book { repo: string | undefined path: string | undefined + /** The install's own base (PathsAnswer.realBase), once `paths` answered. */ + realBase: string | undefined private etag: string | undefined private timer: { cancel(): void } | undefined private inFlight = false @@ -153,19 +202,33 @@ export class Book { private engine: Engine, private claims: Claims, private status: StatusLine, - private opts: { hookVersion?: string; toHook?: (row: Record) => HookRule | null } = {}, + private opts: { + hookVersion?: string + toHook?: (row: Record) => HookRule | null + /** Told what `paths` answered for the session's cwd, once, before anything is loaded. */ + onPaths?: (answer: PathsAnswer) => void + /** Told the repository and rule count each time rows are handed to the engine. */ + onRules?: (repo: string, count: number) => void + } = {}, ) {} /** * At session.start: find the book, read it, hand it to the engine. A * missing cache is fetched once, here. Resolves true once the engine holds * a book (then the lanes may be claimed), false when there is none to hold - * (signed out with no cache, no repo): Python then serves, with no book - * either. Rejects on a real failure (the caller releases every lane). + * (signed out with no cache, no repo, or a repo the machine's allowlist + * leaves out): Python then serves, with no book either — and in the + * allowlist case stays silent too, reading the same file. Rejects on a + * real failure (the caller releases every lane). */ async load(cwd: string): Promise { - const paths = await pathsFor(this.io, this.ctx, cwd) + const answer = await askPaths(this.io, this.ctx, cwd) + this.realBase = answer.realBase + this.opts.onPaths?.(answer) + const paths = answer.paths if (!paths) return false + // Dormant here (repo_allow.py): nothing to serve, nothing to claim, no refresh. + if (!paths.enabled) return false this.repo = paths.repo this.path = paths.book // A recorded upgrade notice suspends the cached rules (rulebook_hook @@ -251,6 +314,7 @@ export class Book { private async hand(rows: readonly unknown[]): Promise { const rules = shapeRules(rows, this.opts.toHook ?? hookRuleFor(this.opts.hookVersion)) this.engine.setBook(rules, { repo: this.repo!, fetchedAt: await this.io.now() }) + this.opts.onRules?.(this.repo!, rules.length) } /** diff --git a/plugins/memhub/mod/claims.ts b/plugins/memhub/mod/claims.ts index 190543cc..702798a4 100644 --- a/plugins/memhub/mod/claims.ts +++ b/plugins/memhub/mod/claims.ts @@ -221,6 +221,23 @@ export class Claims { await this.write() } + /** + * Hand ONE call to Python without counting a failure — a call the mod's + * book does not cover (another repo's checkout). The lanes leave the + * variable, so the Python hooks beneath serve the call, until `recover` + * takes each back. Resolves the lanes actually yielded. + */ + async yieldCall(lanes: readonly LaneName[]): Promise { + const took = lanes.filter(l => this.claimed.has(l)) + if (!took.length) return took + for (const l of took) { + this.claimed.delete(l) + this.paused.add(l) + } + await this.write() + return took + } + /** After the call a first failure handed to Python: take the lane back. */ async recover(lane: LaneName): Promise { if (!this.paused.delete(lane) || this.lost.has(lane)) return diff --git a/plugins/memhub/mod/ctx.ts b/plugins/memhub/mod/ctx.ts index 1712fe2d..ba65279f 100644 --- a/plugins/memhub/mod/ctx.ts +++ b/plugins/memhub/mod/ctx.ts @@ -26,6 +26,13 @@ export type Api = { studio?: string } +/** + * What the last credential lookup said: 'in' with a credential, 'out' when + * api-info answered signed out, 'unknown' when it could not answer at all + * (no python3, a broken install): never taken for a signed-out person. + */ +export type SignIn = 'in' | 'out' | 'unknown' + export type Ctx = { /** 'staging' for memhub-staging, 'prod' for memhub (promote_export.py changes the name). */ env: Env @@ -39,8 +46,46 @@ export type Ctx = { api(): Promise /** Drop the cached credential (after a 401) so the next api() re-resolves. */ forgetApi(): void + /** + * Whether MemHub is active in the session's repository: false when the + * machine's repository allowlist (`~/.config/memhub-plugin/repos.json`, + * scripts/repo_allow.py) leaves it out, which `rulebook_mod_cli.py paths` + * answers as `enabled`. The shell settles it at boot (register.ts) once it + * has asked; true where nothing could ask. The companion stays hidden in + * a repository MemHub is inactive in, as the Python lanes stay silent there. + * Resolves to the LATEST answer: the strip's Yes (ENG-1253) lists the + * repository mid-session and sets it again. + */ + active(): Promise + /** + * The shell's answer (register.ts `boot`), and later the strip's: the + * first call settles anyone waiting, each call is what `active()` says next. + */ + setActive(value: Active): void + /** The book the shell holds for the session's repository, once loaded (the companion's status strip reads it). */ + book(): BookInfo | undefined + setBook(info: BookInfo): void + /** What the last api() lookup said of the sign-in (see SignIn); 'unknown' before any. */ + signIn(): SignIn } +/** What the status strip says of the book: the repository and how many rules the mod holds for it. */ +export type BookInfo = { repo: string; rules: number } + +/** + * The allowlist's answer for the session: `enabled` false where MemHub is + * dormant, and `repo` the session's repository name — '' outside any checkout, + * which with a repos.json is dormant too (there is no repository to list). + * The rest is what the strip's enable ask (ENG-1253) needs, all from `paths` + * so nothing re-reads repos.json in TypeScript: `restricted`, a repos.json + * exists; `declined`, the person answered No to this repository before; + * `identity`, the checkout's name as the list carries it (`org/name`). + * Each is optional: a shell that could not ask leaves them out, and the ask + * then never shows. + */ +export type Active = { enabled: boolean; repo: string; restricted?: boolean; declined?: boolean; identity?: string } +export const ACTIVE: Active = { enabled: true, repo: '' } + /** The keys of this plugin's `$.state` contract (types/index.d.ts) the shell writes. */ export type StateShape = { fires: FireNote[] @@ -117,35 +162,63 @@ export function makeCtx(io: Io, env: Env): Ctx { // The resolved credential, kept for the session. A signed-out or failed // answer is not kept: a person who runs /memhub:login mid-session is picked // up on the next refresh tick (only the tick and the judge ask). - let cached: Promise | undefined - const resolve = async (): Promise => { + let said: SignIn = 'unknown' + // what a lookup said is kept only while it is the lookup in `cached`: one + // overtaken by a forgetApi() and a newer lookup must not overwrite its answer + const resolve = async (): Promise<{ api?: Api; said: SignIn }> => { const r = await io.run(['python3', `${io.root}/${MOD_CLI}`, 'api-info', '--env', env], { timeoutMs: 10_000 }) if (r.exitCode !== 0) throw new Error(`api-info exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`) const got = lastJson(r.stdout) as Partial | undefined if (got && typeof got.base === 'string' && got.base && typeof got.bearer === 'string' && got.bearer) { const studio = typeof got.studio === 'string' ? got.studio.replace(/\/+$/, '') : '' - return { base: got.base.replace(/\/+$/, ''), bearer: got.bearer, ...(studio ? { studio } : {}) } + return { api: { base: got.base.replace(/\/+$/, ''), bearer: got.bearer, ...(studio ? { studio } : {}) }, said: 'in' } } - return undefined + // `{}` is api-info's signed out; anything else on stdout is not an answer + return { said: got && typeof got === 'object' && Object.keys(got).length === 0 ? 'out' : 'unknown' } } + let cached: Promise<{ api?: Api; said: SignIn }> | undefined + const api = async (): Promise => { + if (!cached) cached = resolve() + const mine = cached + try { + const got = await mine + if (cached === mine) { + said = got.said + if (!got.api) cached = undefined + } + return got.api + } catch { + if (cached === mine) { + said = 'unknown' + cached = undefined + } + return undefined + } + } + let settleActive: (value: Active) => void = () => undefined + const firstActive = new Promise(r => { + settleActive = r + }) + // the latest answer, once there is one: the first waits on the shell's boot + let current: Active | undefined + let bookInfo: BookInfo | undefined return { env, root: io.root, - async api() { - if (!cached) cached = resolve() - const mine = cached - try { - const api = await mine - if (!api && cached === mine) cached = undefined - return api - } catch { - if (cached === mine) cached = undefined - return undefined - } + active: () => (current ? Promise.resolve(current) : firstActive), + setActive(value) { + current = value + settleActive(value) + }, + book: () => bookInfo, + setBook(info) { + bookInfo = info }, + api, forgetApi() { cached = undefined }, + signIn: () => said, } } diff --git a/plugins/memhub/mod/engine/engine.ts b/plugins/memhub/mod/engine/engine.ts index 2ca50838..54fe5461 100644 --- a/plugins/memhub/mod/engine/engine.ts +++ b/plugins/memhub/mod/engine/engine.ts @@ -35,9 +35,14 @@ // * no `show_upgrade` (a book suspended by an upgrade notice is never loaded: // book.ts), no `maybe_refresh` / `refresh_if_stale` (book.ts refreshes on a // timer), no `.sources` audit file, no breadcrumbs, no legacy conversions; -// * a call whose checkout belongs to ANOTHER repo than the loaded book -// throws ForeignRepoError: the lane hands that call to Python (which loads -// that repo's book) rather than judge it against the wrong rules; +// * ONE book per session: a call whose checkout belongs to ANOTHER repo +// than the loaded book — the file it edits or reads, or where its Bash +// command runs (`cd ~/other && …`, `git -C ../x`, `$VAR` expanded: +// Repos.ofCall, the port of `repo_of_call`) — throws ForeignRepoError, +// and the shell hands BOTH halves of that call to Python (lanes.ts), +// which loads that repo's book, rather than judge it against the wrong +// rules. A hand-off for a foreign repo is not a failure: it never costs +// the session its claim; // * a rule pyre cannot express (`_unportable`) is never skipped: while one // is in the book every tool and prompt lane throws UnportableRuleError, // the shell hands the call to Python and, on the second, releases the @@ -66,12 +71,14 @@ import { import { givenOk, Probes, pyCompare, type SpecHit } from './probes' import { isoMicros, pyExec, pyMatch } from './py' import { pySearch } from './pyre' +import { dirname } from './py' import { Repos, sessionFileName, worktreeKey, type RepoInfo } from './repo' import { bookRank, cpLen, cpSlice, disclosureLine, EDIT_TOOLS, editAddedText, evaluate, isDict, oneLine, pyStr, pyStrip, READ_TOOLS, truthy, } from './rules' import { - anchorHits, bashReads, join, pathInScope, redactSecrets, relpath, shellOnly, stripComments, + anchorHits, bashDirs, bashReads, bashWriteTargets, basename, join, pathInScope, redactSecrets, relpath, shellOnly, stripComments, + varNames, type Env, } from './shell' type Dict = Record @@ -233,10 +240,15 @@ type SessionState = JudgeState & { armed_version: Record spec_pending: Record bash_t0: Map + /** Per Bash call: [rule id, real path] its pre lane fired on (`bash_pre`). */ + bash_pre: Map } function freshState(): SessionState { - return { fired: [], counts: {}, raw: {}, armed: {}, armed_once: [], armed_version: {}, spec_pending: {}, bash_t0: new Map() } + return { + fired: [], counts: {}, raw: {}, armed: {}, armed_once: [], armed_version: {}, spec_pending: {}, bash_t0: new Map(), + bash_pre: new Map(), + } } /** `load_state`'s defaulting, over a parsed file. */ @@ -256,11 +268,21 @@ function stateFrom(doc: unknown): SessionState { if (isDict(doc.bash_t0)) { for (const [k, v] of Object.entries(doc.bash_t0)) if (typeof v === 'number') st.bash_t0.set(k, v) } + if (isDict(doc.bash_pre)) { + for (const [k, v] of Object.entries(doc.bash_pre)) { + if (Array.isArray(v)) { + st.bash_pre.set(k, v.filter((x): x is [string, string] => + Array.isArray(x) && x.length === 2 && typeof x[0] === 'string' && typeof x[1] === 'string')) + } + } + } return st } const cloneState = (st: SessionState): SessionState => { - const c = stateFrom(JSON.parse(JSON.stringify({ ...st, bash_t0: Object.fromEntries(st.bash_t0) }))) + const c = stateFrom(JSON.parse(JSON.stringify({ + ...st, bash_t0: Object.fromEntries(st.bash_t0), bash_pre: Object.fromEntries(st.bash_pre), + }))) if (!st.judge) delete c.judge return c } @@ -277,8 +299,11 @@ type Ev = { added?: string | null rtext: string resp: { text: string; isError: boolean } | null - via: 'bash' | 'bash-read' | null + /** 'bash-pre': a file the Bash command is about to write; 'bash': one it wrote (post lane). */ + via: 'bash' | 'bash-pre' | 'bash-read' | null read: Dict | null + /** The worktree a synthetic edit's file is in, when not the call's. */ + root?: string } type Ctx = { @@ -288,6 +313,9 @@ type Ctx = { branch: string tool: string worktree: string | null + /** The call this fire is anchored on — local-only, like `host`; the Stop + * lane builds the effect judge's window from it (fire_effect.py). */ + tool_use_id?: string | null } // ── the engine ────────────────────────────────────────────────────────────── @@ -326,13 +354,50 @@ class RulebookEngine implements Engine { return this.rules.map((r) => ({ ...r })) } - private async where(cwd: string, tool: string, inp: Dict): Promise { - const info = await this.repos.ofCall(cwd, tool, inp) + private async where(cwd: string, tool: string, inp: Dict, vars: Env = {}): Promise { + const info = await this.repos.ofCall(cwd, tool, inp, vars) if (!info.repo) return undefined if (info.repo !== this.repo) throw new ForeignRepoError(info.repo, this.repo) return info } + /** + * A Bash command that writes or reads a file in ANOTHER repo's checkout + * (`echo > ../other/x.csv`, `tee ~/other/app/x.py`, `cat $HOME/other/README.md`) + * has that file judged by that repo's book (rulebook_hook `_file_book`). + * The mod holds one book, so it hands the call to Python, which loads it. + */ + private async foreignTargets(cwd: string, cmd: string, vars: Env): Promise { + if (!cmd) return + let cleaned = cmd + const found = findOverride(cmd) + if (found) cleaned = stripOverride(cmd, found) + let paths: string[] + try { + paths = [ + ...bashWriteTargets(cwd, cleaned, vars).map(([p]) => p), + ...bashReads(cwd, cleaned, this.io.home).map(([p]) => p), + ] + } catch { + return // the lane itself meets the same input and answers as Python does + } + for (const p of paths) { + const info = await this.repos.repoInfo(dirname(p)) + if (info.repo && info.repo !== this.repo) throw new ForeignRepoError(info.repo, this.repo) + } + } + + /** The environment a Bash command's paths expand in: HOME and the variables it names. */ + private async varsFor(cmd: string): Promise { + const env: Record = this.io.home ? { HOME: this.io.home } : {} + for (const name of varNames(cmd)) { + if (name === 'HOME' || !this.io.getenv) continue + const v = await this.io.getenv(name).catch(() => undefined) + if (v !== undefined) env[name] = v + } + return env + } + private async baseOf(root: string): Promise { const p = await this.io.paths(root).catch(() => undefined) if (!p?.base) throw new Error('no rulebook base') @@ -399,6 +464,10 @@ class RulebookEngine implements Engine { agent_id: ctx.agent_id, worktree: ctx.worktree, source_message_id: null, + tool_use_id: ctx.tool_use_id ?? null, + // Local-only, as Python writes it: the rule's label for the one line a + // repair shows the person (live repair, ENG-1231). + title: pyStr(r._label || r.title || '').slice(0, 120), repo: ctx.repo, branch: ctx.branch, tool: ctx.tool, @@ -482,6 +551,27 @@ class RulebookEngine implements Engine { return (await this.io.stat(p, true))?.realPath ?? p } + /** `os.path.realpath` of a file that may not exist yet: its directory's, resolved. */ + private async realFile(p: string): Promise { + const st = await this.io.stat(p, true) + if (st?.realPath) return st.realPath + const d = (await this.io.stat(dirname(p), true))?.realPath + return d ? join(d, basename(p)) : p + } + + /** `_same_dir(a, b)` */ + private async sameDir(a: string, b: string): Promise { + return (await this.realpath(a)) === (await this.realpath(b)) + } + + /** `_write_root(path, repo, root)`: [worktree, path] when the file is in a checkout of `repo`, else ['', path]. */ + private async writeRoot(path: string, repo: string, root: string): Promise<[string, string]> { + const info = await this.repos.repoInfo(dirname(path)) + if (!info.repo || info.repo !== repo) return ['', path] + if (root && info.root !== root && (await this.sameDir(info.root, root))) return [root, join(root, relpath(path, info.root))] + return [info.root, path] + } + /** `_worktrees(root)` */ private async worktrees(root: string): Promise { let r: { code: number; stdout: string } @@ -512,14 +602,20 @@ class RulebookEngine implements Engine { return [...names] } - /** `bash_written_files(root, cmd, since)` */ - private async bashWrittenFiles(root: string, cmd: string, since: number): Promise<[string, boolean][]> { + /** `bash_written_files(root, cmd, since, also)` */ + private async bashWrittenFiles(root: string, cmd: string, since: number, also: readonly string[]): Promise<[string, boolean, string][]> { if (!root || pySearch(TREE_REWRITE_RX, shellOnly(cmd || ''), 'm')) return [] const roots: string[] = [] for (const w of await this.worktrees(root)) { if (w === root || (await this.namesOf(w)).some((n) => (cmd || '').includes(n))) roots.push(w) } - const out: [string, boolean][] = [] + for (const w of also) { + if (!w) continue + let same = false + for (const r of roots) if (await this.sameDir(w, r)) same = true + if (!same) roots.push(w) + } + const out: [string, boolean, string][] = [] for (const wt of roots) { let r: { code: number; stdout: string } try { @@ -545,7 +641,7 @@ class RulebookEngine implements Engine { const path = join(wt, rel) const st = await this.io.stat(path) if (!st || st.kind !== 'file' || st.mtimeMs / 1000 < since) continue - out.push([path, isNew]) + out.push([path, isNew, wt]) if (out.length > BASH_EDIT_MAX_FILES) return [] } } @@ -615,13 +711,17 @@ class RulebookEngine implements Engine { const out: Verdict = { context: [], fires: [], ledger: [] } if (!this.loaded) return out const inp = (e.input ?? {}) as Dict - const info = await this.where(e.cwd, e.tool, inp) - if (!info) return out + const vars = e.tool === 'Bash' ? await this.varsFor(typeof inp.command === 'string' ? inp.command : '') : {} + const info = await this.where(e.cwd, e.tool, inp, vars) + // No checkout at all: Python judges it on the book of rules that bind + // every repo (rulebook_hook `main`, projectless); the mod holds one repo's. + if (!info) throw new ForeignRepoError('', this.repo) + if (e.tool === 'Bash') await this.foreignTargets(e.cwd, typeof inp.command === 'string' ? inp.command : '', vars) const base = await this.baseOf(info.root) const st = await this.state(e.sessionId, base) const saved = cloneState(st) try { - return await this.callInner(mode, e, inp, info, base, st, out) + return await this.callInner(mode, e, inp, info, base, st, out, vars) } catch (err) { if (err instanceof ForeignRepoError || err instanceof UnportableRuleError) throw err // Python's main(): any exception is a silent exit 0, its state unsaved. @@ -631,7 +731,7 @@ class RulebookEngine implements Engine { } private async callInner( - mode: 'pre' | 'post', e: CallEvent, inp: Dict, info: RepoInfo, base: string, st: SessionState, out: Verdict, + mode: 'pre' | 'post', e: CallEvent, inp: Dict, info: RepoInfo, base: string, st: SessionState, out: Verdict, vars: Env, ): Promise { const io = this.io const { repo, root, gitdir, branch } = info @@ -641,7 +741,10 @@ class RulebookEngine implements Engine { const session = e.sessionId const cwd = e.cwd const agentId = e.agentId && pyStrip(e.agentId) ? cpSlice(pyStrip(e.agentId), 0, 64) : null - const ctx: Ctx = { session, agent_id: agentId, repo, branch, tool, worktree: worktreeKey(root) } + const ctx: Ctx = { + session, agent_id: agentId, repo, branch, tool, worktree: worktreeKey(root), + tool_use_id: e.toolUseId ? pyStr(e.toolUseId) : null, + } const ledger = out.ledger const cmdText = tool === 'Bash' ? pyStr(inp.command || '') : '' let probeRoot = root @@ -701,13 +804,26 @@ class RulebookEngine implements Engine { // The events this call is (main(): synthetic edits FIRST, then the call, then synthetic reads). const real: Ev = { tool, phase: mode, order_phase: mode, cmd, fp, body, added, rtext, resp, via: null, read: null } const events: Ev[] = [] + let preWritten: [string, string][] = [] + const callKey = e.toolUseId ? pyStr(e.toolUseId) : 'last' if (tool === 'Bash') { const marks = st.bash_t0 - const callKey = e.toolUseId ? pyStr(e.toolUseId) : 'last' if (mode === 'pre') { marks.set(callKey, io.now() / 1000) const keys = [...marks.keys()] for (const k of keys.slice(0, Math.max(keys.length - BASH_EDIT_MARKS_KEPT, 0))) marks.delete(k) + if (cmd && rules.some((r) => r.on === 'edit' && isActive(r))) { + for (const [target, text] of bashWriteTargets(cwd, cmd, vars)) { + if ((await io.stat(target))?.kind === 'dir') continue + const [wroot, path] = await this.writeRoot(target, repo, root) + if (wroot) { + events.push({ + tool: 'Write', phase: 'pre', order_phase: 'pre', cmd: '', fp: path, body: text, added: null, rtext: '', + resp: null, via: 'bash-pre', read: null, root: wroot, + }) + } + } + } } else { let t0 = marks.get(callKey) marks.delete(callKey) @@ -715,12 +831,26 @@ class RulebookEngine implements Engine { t0 = marks.get('last') marks.delete('last') } + const done = st.bash_pre + let pw = done.get(callKey) + done.delete(callKey) + if (!pw?.length && callKey !== 'last') { + pw = done.get('last') + done.delete('last') + } + preWritten = pw ?? [] const wantsEdits = rules.some((r) => (r.on === 'edit' || r.on === 'ordering') && isActive(r)) if (t0 !== undefined && wantsEdits) { - for (const [path, isNew] of await this.bashWrittenFiles(root, cmd, t0)) { + const also: string[] = [] + const dirs = [...bashDirs(cwd, cmd, vars), ...bashWriteTargets(cwd, cmd, vars).map(([p]) => dirname(p)), cwd] + for (const d of dirs) { + const w = (await this.writeRoot(join(d, '_'), repo, root))[0] + if (w && !also.includes(w)) also.push(w) + } + for (const [path, isNew, wt] of await this.bashWrittenFiles(root, cmd, t0, also)) { const text = await this.readEditBody(path, isNew) if (text === null) continue - events.push({ tool: 'Write', phase: 'pre', order_phase: 'post', cmd: '', fp: path, body: text, rtext: '', resp: null, via: 'bash', read: null }) + events.push({ tool: 'Write', phase: 'pre', order_phase: 'post', cmd: '', fp: path, body: text, rtext: '', resp: null, via: 'bash', read: null, root: wt }) } } } @@ -811,7 +941,8 @@ class RulebookEngine implements Engine { if (firedOn.has(rid)) continue if (r.on === 'ordering') { - if (EDIT_TOOLS.includes(etool) && !pathInScope(sc(r), efp, root)) continue + if (ev.via === 'bash-pre') continue + if (EDIT_TOOLS.includes(etool) && !pathInScope(sc(r), efp, ev.root || root)) continue if (rid in st.armed && rid in st.armed_version && (st.armed_version[rid] ?? null) !== (r._version ?? null)) dropArming(rid) let outcome: string | null try { @@ -831,9 +962,13 @@ class RulebookEngine implements Engine { continue } - if (!pathInScope(sc(r), EDIT_TOOLS.includes(etool) || READ_TOOLS.includes(etool) ? efp : '', root)) continue + if (!pathInScope(sc(r), EDIT_TOOLS.includes(etool) || READ_TOOLS.includes(etool) ? efp : '', ev.root || root)) continue + if (ev.via === 'bash' && preWritten.length) { + const realFp = await this.realFile(efp) + if (preWritten.some(([a, b]) => a === rid && b === realFp)) continue + } let scope = has(r, 'fire_scope') ? r.fire_scope : 'session' - if (ephase === 'pre' && r.mode === 'gate' && (ev.via === null || ev.via === 'bash-read') && + if (ephase === 'pre' && r.mode === 'gate' && (ev.via === null || ev.via === 'bash-read' || ev.via === 'bash-pre') && ((etool === 'Bash' && r.on === 'bash') || (EDIT_TOOLS.includes(etool) && r.on === 'edit') || (READ_TOOLS.includes(etool) && r.on === 'read'))) { scope = 'call' @@ -884,7 +1019,7 @@ class RulebookEngine implements Engine { const excerptOf = (r: HookRule): string => { const ev = firedOn.get(r.id) - if (ev?.via === 'bash') return `bash-edit ${ev.fp}` + if (ev?.via === 'bash' || ev?.via === 'bash-pre') return `bash-edit ${ev.fp}` if (ev?.via === 'bash-read') return `bash-read ${ev.fp}` return cmd || fp || '' } @@ -935,7 +1070,9 @@ class RulebookEngine implements Engine { if (mode !== 'pre' || r.mode !== 'gate') return false const via = fireOf(r)?.via ?? null if (via === 'bash') return false - if (tool === 'Bash') return r.on === 'bash' || r.on === 'ordering' || (r.on === 'read' && via === 'bash-read') + if (tool === 'Bash') { + return r.on === 'bash' || r.on === 'ordering' || (r.on === 'read' && via === 'bash-read') || (r.on === 'edit' && via === 'bash-pre') + } if (READ_TOOLS.includes(tool)) return r.on === 'read' return EDIT_TOOLS.includes(tool) && r.on === 'edit' } @@ -994,9 +1131,11 @@ class RulebookEngine implements Engine { const whereOf = (r: HookRule): string => { const ev = firedOn.get(r.id) - if (!ev || (ev.via !== 'bash' && ev.via !== 'bash-read')) return '' + if (!ev || (ev.via !== 'bash' && ev.via !== 'bash-pre' && ev.via !== 'bash-read')) return '' let path = ev.fp - if (root && path.startsWith(root.replace(/\/+$/, '') + '/')) path = relpath(path, root) + const eroot = ev.root || root + if (eroot && path.startsWith(eroot.replace(/\/+$/, '') + '/')) path = relpath(path, eroot) + if (ev.via === 'bash-pre') return ` _(in \`${path}\`, which that command writes)_` if (ev.via === 'bash-read') { const n = (ev.read ?? {}).lines return n !== undefined && n !== null ? ` _(\`${path}\`, ${pyStr(n)} lines, read by that command)_` : ` _(\`${path}\`, read by that command)_` @@ -1073,6 +1212,21 @@ class RulebookEngine implements Engine { } for (const r of cut) { ledger.push(this.fireRow(ctx, r, mode, 'suppressed', excerptOf(r), { raw, dedup: dedupKeys, at: firedAt, judge: judged })) + // Cut, not spent: the rule was not shown, so it may fire on a later call. + unfire(st, r, dedupKeys[r.id], marks, true) + } + if (mode === 'pre' && tool === 'Bash') { + const wrote: [string, string][] = [] + for (const r of shown) { + const ev = firedOn.get(r.id) + if (ev?.via === 'bash-pre') wrote.push([r.id, await this.realFile(ev.fp)]) + } + if (wrote.length) { + st.bash_pre.delete(callKey) + st.bash_pre.set(callKey, wrote) + const keys = [...st.bash_pre.keys()] + for (const k of keys.slice(0, Math.max(keys.length - BASH_EDIT_MARKS_KEPT, 0))) st.bash_pre.delete(k) + } } for (const r of shown) { st.raw[r.id] = 0 @@ -1137,6 +1291,7 @@ class RulebookEngine implements Engine { const probes = new Probes(this.io, root, branch, '', null, this.turns(), cwd, env.baseBranch ?? '') const fired: HookRule[] = [] const dedupKeys: Record = {} + const marks: Marks = { fired: [...st.fired], counts: { ...st.counts }, spec_pending: { ...st.spec_pending } } for (const r of live) { const rid = r.id const scope = has(r, 'fire_scope') ? r.fire_scope : 'session' @@ -1176,7 +1331,10 @@ class RulebookEngine implements Engine { const m = pyExec(pyStr(r.rx), text, 'im') out.ledger.push(this.fireRow(ctx, r, 'prompt', 'advise', m ? m[0] : '', { raw, dedup: dedupKeys, at: firedAt })) } - for (const r of cut) out.ledger.push(this.fireRow(ctx, r, 'prompt', 'suppressed', '', { raw, dedup: dedupKeys, at: firedAt })) + for (const r of cut) { + out.ledger.push(this.fireRow(ctx, r, 'prompt', 'suppressed', '', { raw, dedup: dedupKeys, at: firedAt })) + unfire(st, r, dedupKeys[r.id], marks, true) // cut, not spent + } for (const r of shown) st.raw[r.id] = 0 } diff --git a/plugins/memhub/mod/engine/index.ts b/plugins/memhub/mod/engine/index.ts index 5da00b59..91867869 100644 --- a/plugins/memhub/mod/engine/index.ts +++ b/plugins/memhub/mod/engine/index.ts @@ -109,6 +109,12 @@ export interface EngineIO { /** $HOME, for `~` and the identity redaction. */ home: string env(): Promise + /** + * One variable of the environment the Python hook would see (`os.environ`), + * for a Bash command's `cd "$WT"` / `> $OUT/f`; undefined when unset. Absent: + * no variable but HOME expands, and a path naming one is unknown. + */ + getenv?(name: string): Promise /** `rulebook_mod_cli.py paths --cwd `; undefined when it cannot answer. */ paths(dir: string): Promise /** One write through `rulebook_mod_state.py`; undefined when the helper failed as a whole. */ diff --git a/plugins/memhub/mod/engine/repo.ts b/plugins/memhub/mod/engine/repo.ts index 02aeb05c..c0823903 100644 --- a/plugins/memhub/mod/engine/repo.ts +++ b/plugins/memhub/mod/engine/repo.ts @@ -1,5 +1,5 @@ // Which checkout a call works in: a port of rulebook_hook.py repo_info, -// _branch, _acted_on_dir, repo_of_call, command_root and worktree_key, over +// _branch, _call_seed, repo_of_call, command_root and worktree_key, over // EngineIO (stat / readText) instead of os.path. // // The repo NAME is the one thing not re-derived here: repo_identity resolves @@ -9,14 +9,14 @@ import type { EngineIO } from './index' import { dirname, pyMatch, sha1Hex } from './py' import { pyStrip } from './rules' -import { basename, cdPrefixTarget, expanduser, isabs, join, normpath, under } from './shell' +import { findOverride, stripOverride } from './override' +import { basename, bashReads, bashWriteTargets, callDir, expanduser, isabs, join, normpath, type Env } from './shell' export type RepoInfo = { repo: string; root: string; gitdir: string; branch: string } export const NO_REPO: RepoInfo = { repo: '', root: '', gitdir: '', branch: '' } const ARG = String.raw`(?:'([^']*)'|"([^"]*)"|([^\s;&|]+))` const CD_PREFIX = String.raw`^\s*cd\s+` + ARG + String.raw`\s*(?:&&|;)` -const SEED_MAX_HOPS = 64 /** `worktree_key(root)`: the 16-hex checkout id. */ export const worktreeKey = (root: string): string | null => (root ? sha1Hex(root).slice(0, 16) : null) @@ -94,26 +94,17 @@ export class Repos { return (await this.io.stat(p, true))?.realPath } - /** `_acted_on_dir(cwd, inp)`: the acted-on file's directory, inside the session cwd, else "". */ - async actedOnDir(cwd: string, inp: Readonly>): Promise { - if (!cwd) return '' - const base = normpath(cwd) + /** `_call_seed(cwd, data)` for a Claude Code call: the directory the call acts in, or "". */ + callSeed(cwd: string, tool: string, inp: Readonly>, env: Env): string { for (const key of ['file_path', 'notebook_path']) { const fp = inp[key] - if (typeof fp !== 'string' || !fp || fp.includes('\0')) continue + if (typeof fp !== 'string' || !pyStrip(fp) || fp.includes('\0')) continue let d = dirname(fp.replace(/\\/g, '/')) if (!isabs(d)) d = join(cwd, d) - d = normpath(d) - if (!under(d, base)) continue - let probe = d - let hops = 0 - while (!(await this.io.stat(probe)) && dirname(probe) !== probe && hops < SEED_MAX_HOPS) { - probe = dirname(probe) - hops += 1 - } - const [rp, rc] = await Promise.all([this.realpath(probe), this.realpath(cwd)]) - if (rp !== undefined && rc !== undefined && under(rp, rc)) return d + return normpath(d) } + const command = inp.command + if (tool === 'Bash' && typeof command === 'string') return callDir(cwd, command, env) return '' } @@ -129,20 +120,48 @@ export class Repos { return (await this.repoInfo(path)).root } - /** `repo_of_call(data)` for a Claude Code call (no apply_patch). */ - async ofCall(cwd: string, tool: string, inp: Readonly>): Promise { - const seed = await this.actedOnDir(cwd, inp) + /** + * `repo_of_call(data)` for a Claude Code call (no apply_patch): the checkout + * the call acts in — the file it edits or reads, wherever it is, or where + * its Bash command runs — else the cwd's. A target that is the cwd's own + * checkout spelled another way answers with the cwd's spelling (one + * worktree, one ordering key). + */ + async ofCall(cwd: string, tool: string, inp: Readonly>, env: Env = {}): Promise { + const here = await this.repoInfo(cwd) + const seed = this.callSeed(cwd, tool, inp, env) if (seed) { const info = await this.repoInfo(seed) - if (info.repo) return info + if (info.repo) { + if (here.repo && info.root !== here.root && + ((await this.realpath(info.root)) ?? info.root) === ((await this.realpath(here.root)) ?? here.root)) return here + return info + } } - const info = await this.repoInfo(cwd) - if (info.repo) return info - const command = inp.command - if (tool === 'Bash' && typeof command === 'string' && cdPrefixTarget(command) !== null) { - const root = await this.commandRoot(cwd, command) - if (root) return this.repoInfo(root) + // Neither the cwd nor where the command runs is a checkout: the first + // file the command writes or reads that is in one decides (`_bash_targets`). + if (!here.repo) { + for (const p of this.bashTargets(cwd, tool, inp, env)) { + const info = await this.repoInfo(dirname(p)) + if (info.repo) return info + } + } + return here + } + + /** `_bash_targets(cwd, data)`: the files a Bash call writes, then reads (override stripped); [] otherwise. */ + bashTargets(cwd: string, tool: string, inp: Readonly>, env: Env): string[] { + let cmd = inp.command + if (tool !== 'Bash' || typeof cmd !== 'string' || !cmd) return [] + try { + const found = findOverride(cmd) + if (found) cmd = stripOverride(cmd, found) + return [ + ...bashWriteTargets(cwd, cmd as string, env).map(([p]) => p), + ...bashReads(cwd, cmd as string, this.io.home).map(([p]) => p), + ] + } catch { + return [] } - return info } } diff --git a/plugins/memhub/mod/engine/shell.ts b/plugins/memhub/mod/engine/shell.ts index 2a88f455..c4783d0b 100644 --- a/plugins/memhub/mod/engine/shell.ts +++ b/plugins/memhub/mod/engine/shell.ts @@ -36,6 +36,7 @@ // The caller runs `command_root`/`repo_info` on a non-null answer. // * `redact_identities` reads this machine's home: `redactIdentities(text, home)`. +import { pyExec, pyFinditer, pyMatch } from './py' import { pyCompile, pySearch } from './pyre' // ── Python character classes ──────────────────────────────────────────────── @@ -1081,6 +1082,366 @@ export function judgeMatchedOn( return out } +// ── where a Bash call works, and what it writes (pure) ────────────────────── +// Ports of rulebook_hook.expand_path, _call_segments, _walk_call, call_dir, +// bash_dirs, heredoc_bodies, _code_writes, _s_replacements, _inplace_edit and +// bash_write_targets. `env` stands in for os.environ (HOME included): the +// caller builds it from the variables the command names. + +/** An environment as `os.environ` answers `.get(name)`. */ +export type Env = Readonly> +const envGet = (env: Env, k: string): string | undefined => + Object.prototype.hasOwnProperty.call(env, k) ? env[k] : undefined + +const VAR_RX = String.raw`\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))` +const INTERP_RX = String.raw`^(?:python[0-9.]*|node)$` +export const BASH_EDIT_MAX_FILES = 40 +export const BASH_EDIT_MAX_BYTES = 512 * 1024 + +/** The variable names a command spells (`$X`, `${X}`), for the caller to look up. */ +export function varNames(cmd: string): string[] { + const out = new Set() + for (const m of pyFinditer(VAR_RX, cmd || '')) out.add((m[1] ?? m[2])!) + return [...out] +} + +/** `expand_path(token, here, env)` */ +export function expandPath(token: unknown, here: string, env: Env): string { + if (typeof token !== 'string' || !token || token.includes('$(') || [...'`*?[]\n\r\0'].some((c) => token.includes(c))) return '' + let missing = false + let t = '' + let last = 0 + for (const m of pyFinditer(VAR_RX, token)) { + const v = envGet(env, (m[1] ?? m[2])!) + if (v === undefined) missing = true + t += token.slice(last, m.index) + (v ?? '') + last = m.index + m[0].length + } + t += token.slice(last) + if (missing || t.includes('$') || t.includes('{') || t.includes('}')) return '' + if (t === '~' || t.startsWith('~/')) { + const home = envGet(env, 'HOME') || '' + if (!home) return '' + t = home + t.slice(1) + } else if (t.startsWith('~')) { + return '' + } + if (!isabs(t)) { + if (!here) return '' + t = join(here, t) + } + return normpath(t) +} + +/** `_call_segments(cmd)`: [separator before, segment] split outside quotes. */ +function callSegments(cmd: string): [string, string][] { + const text = shellOnly(cmd || '') + const blank = blankQuoted(text) + const map = u2c(blank) + const cps = Array.from(text) + const out: [string, string][] = [] + let pos = 0 + let sep = '' + for (const m of blank.matchAll(SEPARATOR_RX)) { + out.push([sep, cps.slice(pos, map[m.index!]).join('')]) + sep = m[0] + pos = map[m.index! + m[0].length]! + } + out.push([sep, cps.slice(pos).join('')]) + return out +} + +type Seg = { dir: string; toks: string[]; sep: string; text: string } + +/** `_walk_call(cwd, cmd, env)` */ +function walkCall(cwd: string, cmd: string, env: Env): [Seg[], string] { + let here = cwd ? normpath(cwd) : '' + const stack: string[] = [] + const out: Seg[] = [] + for (const [sep, seg] of callSegments(cmd)) { + const text = pyStrip(stripChars(pyStrip(stripLeadingAssignments(pyStrip(seg))), '({ \t', 'left')) + if (!text) continue + let toks: string[] + try { + toks = shlexSplit(text) + } catch (e) { + if (!(e instanceof ShlexError)) throw e + if (pyMatch(String.raw`(?:cd|pushd|popd)\b`, text)) here = '' + continue + } + toks = toks.filter((t) => t !== ')' && t !== '}' && t !== '))') + if (!toks.length) continue + if (toks[0] === 'cd' || toks[0] === 'pushd') { + const args = toks.slice(1).filter((t) => !(t.startsWith('-') && cpLen(t) > 1)) + if (toks[0] === 'pushd') stack.push(here) + if (!args.length) here = toks[0] === 'cd' ? expandPath('~', '', env) : '' + else if (args[0] === '-') here = '' + else here = expandPath(args[0], here, env) + continue + } + if (toks[0] === 'popd') { + here = stack.length ? stack.pop()! : '' + continue + } + let d = here + if (basename(toks[0]!) === 'git') { + let i = 1 + while (i < toks.length && toks[i]!.startsWith('-')) { + const t = toks[i]! + if (t === '-C' && i + 1 < toks.length) { + d = expandPath(toks[i + 1], d, env) + i += 2 + } else if (t.startsWith('-C') && cpLen(t) > 2) { + d = expandPath(cpSlice(t, 2), d, env) + i += 1 + } else if (t === '-c' && i + 1 < toks.length) { + i += 2 + } else { + i += 1 + } + } + } + out.push({ dir: d, toks, sep, text }) + } + return [out, here] +} + +/** `call_dir(cwd, cmd, env)` */ +export function callDir(cwd: string, cmd: string, env: Env): string { + const [segs, final] = walkCall(cwd, cmd, env) + return segs.length ? segs[0]!.dir : final +} + +/** `bash_dirs(cwd, cmd, env)` */ +export function bashDirs(cwd: string, cmd: string, env: Env): string[] { + const [segs, final] = walkCall(cwd, cmd, env) + const out: string[] = [] + for (const d of [...segs.map((s) => s.dir), final]) if (d && !out.includes(d)) out.push(d) + return out +} + +/** `heredoc_bodies(cmd)` */ +export function heredocBodies(cmd: string): string[] { + const out: string[] = [] + let skip: string | null = null + let body: string[] = [] + for (const line of (cmd || '').split('\n')) { + if (skip !== null) { + if (pyStrip(line) === skip) { + out.push(body.join('\n')) + skip = null + } else { + body.push(line) + } + continue + } + const m = HD_OPEN.exec(line) + if (m) { + skip = m[2]! + body = [] + } + } + if (skip !== null) out.push(body.join('\n')) + return out +} + +const REDIR_OUT_RX = String.raw`(?&])(?:1?>>?|&>>?|>\|)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()]+))` +// No backreferences (pyre has none): each quote style is its own alternative. +const QUOTED_PATH = String.raw`(?:'([^'\n]+)'|"([^"\n]+)")` +const PY_OPEN_W_RX = String.raw`\bopen\(\s*` + QUOTED_PATH + + String.raw`\s*,\s*(?:mode\s*=\s*)?[rRbBuUfF]?(?:'[^'\n]*[wax+][^'\n]*'|"[^"\n]*[wax+][^"\n]*")` +const PY_PATH_W_RX = String.raw`\bPath\(\s*` + QUOTED_PATH + String.raw`\s*\)\s*\.write_(?:text|bytes)\(` +const PY_ASSIGN_RX = String.raw`\b([A-Za-z_]\w*)\s*=\s*(?:(?:pathlib\.)?Path\(\s*)?` + QUOTED_PATH +const JS_WRITE_RX = String.raw`\b(?:writeFileSync|appendFileSync|writeFile|appendFile)\(\s*` + QUOTED_PATH +const PY_LITERAL_RX = String.raw`"""([\s\S]*?)"""|'''([\s\S]*?)'''|"((?:\\.|[^"\\\n])*)"|'((?:\\.|[^'\\\n])*)'` +const MODE_LITERAL_RX = String.raw`^(?:[rwabxt+]{1,3}|utf-?8)$` +// The text being REPLACED is not written (`_OLD_TAIL_RX`, `_OLD_NAME_RX`). +const REPLACER = String.raw`(?:\.replace|\b_?(?:rep|replace|swap|sub)\w*)\(` +const OLD_TAIL_RX = String.raw`(?:` + REPLACER + String.raw`|(?:^|[\[,=\s])\()\s*$` +const OLD_NAME_RX = REPLACER + String.raw`\s*([A-Za-z_]\w*)\s*,` + +/** `_code_writes(code)`: [paths opened for writing, the text written]. */ +function codeWrites(code: string): [string[], string] { + const paths = [PY_OPEN_W_RX, PY_PATH_W_RX, JS_WRITE_RX].flatMap((rx) => pyFinditer(rx, code).map((m) => (m[1] || m[2])!)) + for (const m of pyFinditer(PY_ASSIGN_RX, code)) { + const name = reEscape(m[1]!) + if (pyExec(String.raw`\b` + name + String.raw`\s*\.write_(?:text|bytes)\(`, code) || + pyExec(String.raw`\bopen\(\s*` + name + String.raw`\s*,\s*(?:mode\s*=\s*)?[rRbBuUfF]?['"][^'"\n]*[wax+]`, code)) { + paths.push((m[2] || m[3])!) + } + } + if (!paths.length) return [[], ''] + const lits: string[] = [] + const olds = [...new Set(pyFinditer(OLD_NAME_RX, code).map((m) => m[1]!))] + const oldBound = olds.length ? String.raw`\b(?:` + olds.map(reEscape).join('|') + String.raw`)\s*=\s*$` : null + const cps = Array.from(code) + const map = u2c(code) + for (const m of pyFinditer(PY_LITERAL_RX, code)) { + const at = map[m.index]! + const before = cps.slice(Math.max(0, at - 80), at).join('') + if (pyExec(OLD_TAIL_RX, before) || (oldBound !== null && pyExec(oldBound, before))) continue + const lit = m[1] !== undefined || m[2] !== undefined ? (m[1] ?? m[2])! : (m[3] ?? m[4])!.split('\\n').join('\n') + if (lit && !paths.includes(lit) && !pyMatch(MODE_LITERAL_RX, lit, 'i')) lits.push(lit) + } + return [paths, lits.join('\n')] +} + +/** `_s_replacements(expr)` */ +function sReplacements(expr: string): string[] { + const out: string[] = [] + const cs = Array.from(expr || '') + const map = u2c(expr || '') + for (const m of pyFinditer(String.raw`(?:^|[;\s}0-9$/])s([^\w\s\\])`, expr || '')) { + const delim = m[1]! + let i = map[m.index + m[0].length]! + const parts: string[] = [] + let cur: string[] = [] + while (i < cs.length && parts.length < 2) { + const c = cs[i]! + if (c === '\\' && i + 1 < cs.length) { + cur.push(cs[i + 1] === delim ? cs[i + 1]! : c + cs[i + 1]!) + i += 2 + continue + } + if (c === delim) { + parts.push(cur.join('')) + cur = [] + } else { + cur.push(c) + } + i += 1 + } + if (parts.length === 2) out.push(parts[1]!) + } + return out +} + +/** `_inplace_edit(toks)`: [files, written text] for `sed -i` / `perl -pi`, else null. */ +function inplaceEdit(toks: string[]): [string[], string] | null { + const name = basename(toks[0]!) + const args = toks.slice(1) + const exprs: string[] = [] + const pos: string[] = [] + let inplace = false + let i = 0 + while (i < args.length) { + const a = args[i]! + if ((a === '-e' || a === '--expression') && i + 1 < args.length) { + exprs.push(args[i + 1]!) + i += 2 + continue + } + if (a.startsWith('--expression=')) { + exprs.push(a.slice(a.indexOf('=') + 1)) + } else if (a.startsWith('--in-place')) { + inplace = true + } else if ((a === '-f' || a === '--file') && i + 1 < args.length) { + i += 2 + continue + } else if (a.startsWith('-') && !a.startsWith('--') && cpLen(a) > 1) { + const flags = a.slice(1) + if (name === 'perl') { + inplace = inplace || flags.includes('i') + if (flags.endsWith('e') && i + 1 < args.length) { + exprs.push(args[i + 1]!) + i += 2 + continue + } + } else if (flags.includes('i')) { + inplace = true + if (a === '-i' && i + 1 < args.length && args[i + 1] === '') { + i += 2 + continue + } + } + } else if (!a.startsWith('--')) { + pos.push(a) + } + i += 1 + } + if (!inplace) return null + if (!exprs.length && pos.length) exprs.push(pos.shift()!) + const text = exprs.flatMap((e) => sReplacements(e)).join('\n') + return [pos.filter((p) => !p.includes('>') && !p.includes('<')), text] +} + +/** `bash_write_targets(cwd, cmd, env)`: [path, text] per file the command visibly writes. */ +export function bashWriteTargets(cwd: string, cmd: string, env: Env): [string, string][] { + const [segs] = walkCall(cwd, cmd, env) + const bodies = heredocBodies(cmd) + const found: [string, string, string][] = [] + let piped = '' + for (const s of segs) { + const { toks, text, dir: d } = s + const body = HD_OPEN.exec(text) && bodies.length ? bodies.shift()! : null + const name = basename(toks[0]!) + const prev = s.sep === '|' ? piped : '' + piped = '' + let outText = '' + if (name === 'echo' || name === 'printf') { + const said: string[] = [] + for (const t of toks.slice(1)) { + if (t.includes('>')) break + if (!(name === 'echo' && pyMatch(String.raw`^-[neE]+$`, t))) said.push(t) + } + outText = said.join(' ') + } else if (name === 'cat' && body !== null) { + outText = body + } + piped = outText + let code: string | null = null + const isInterp = !!pyMatch(INTERP_RX, name) || + (['uv', 'poetry', 'pipenv'].includes(name) && toks.slice(1).some((t) => !!pyMatch(INTERP_RX, basename(t)))) + if (isInterp) { + if (body !== null) code = body + else if (toks.includes('-c') && toks.indexOf('-c') + 1 < toks.length) code = toks[toks.indexOf('-c') + 1]! + else if (toks.includes('-e') && name === 'node' && toks.indexOf('-e') + 1 < toks.length) code = toks[toks.indexOf('-e') + 1]! + } + if (code) { + const [paths, lits] = codeWrites(code) + for (const p of paths) found.push([d, p, lits]) + } + const blank = Array.from(blankQuoted(text)) + const tcs = Array.from(text) + const map = u2c(text) + for (const m of pyFinditer(REDIR_OUT_RX, text)) { + const at = map[m.index]! + if (blank[at] !== tcs[at]) continue + const p = [m[1], m[2], m[3]].find((g) => g) ?? '' + found.push([d, p, ['echo', 'printf', 'cat'].includes(name) ? outText : '']) + } + if (name === 'tee') { + for (const t of toks.slice(1)) { + if (!t.startsWith('-') && !t.includes('>') && !t.includes('<')) found.push([d, t, body !== null ? body : prev]) + } + } else if (name === 'sed' || name === 'perl') { + const got = inplaceEdit(toks) + if (got) for (const p of got[0]) found.push([d, p, got[1]]) + } else if ((name === 'cp' || name === 'mv') && !toks.includes('-t') && !toks.includes('--target-directory')) { + const pos = toks.slice(1).filter((t) => !t.startsWith('-') && !t.includes('>')) + if (pos.length === 2) found.push([d, pos[1]!, '']) + } + } + const out: [string, string][] = [] + const seen = new Map() + for (const [d, p, w] of found) { + const path = expandPath(p, d, env) + if (!path || path.startsWith('/dev/') || (path + '/').includes('/.git/')) continue + const written = cpSlice(w || '', 0, BASH_EDIT_MAX_BYTES) + const i = seen.get(path) + if (i !== undefined) { + if (written && !out[i]![1].includes(written)) { + out[i] = [path, stripChars(stripChars(out[i]![1] + '\n' + written, '\n', 'left'), '\n', 'right')] + } + continue + } + seen.set(path, out.length) + out.push([path, written]) + if (out.length >= BASH_EDIT_MAX_FILES) break + } + return out +} + // ── Python → TS name map ──────────────────────────────────────────────────── // rulebook_hook.shell_only shellOnly // rulebook_hook._HD_OPEN HD_OPEN @@ -1104,7 +1465,11 @@ export function judgeMatchedOn( // rulebook_hook._sed_lines sedLines // rulebook_hook._redirect_base redirectBase (pure core: + facts) // rulebook_hook._under under -// rulebook_hook.repo_of_call (cd prefix) cdPrefixTarget +// rulebook_hook.command_root (cd prefix) cdPrefixTarget +// rulebook_hook.expand_path expandPath (+ env) +// rulebook_hook.call_dir / bash_dirs callDir / bashDirs (+ env) +// rulebook_hook.heredoc_bodies heredocBodies +// rulebook_hook.bash_write_targets bashWriteTargets (+ env) // rulebook_hook.anchor_rx anchorRx / anchorPattern // rulebook_hook.anchor_hits anchorHits // rulebook_hook.path_in_scope pathInScope diff --git a/plugins/memhub/mod/engine/tests/lane_io.ts b/plugins/memhub/mod/engine/tests/lane_io.ts index 8e343f62..dff296f7 100644 --- a/plugins/memhub/mod/engine/tests/lane_io.ts +++ b/plugins/memhub/mod/engine/tests/lane_io.ts @@ -86,6 +86,8 @@ export function fireAsWritten(row: Dict, session: string): Dict { repo: row.repo ?? null, branch: row.branch ?? null, tool: row.tool ?? null, + tool_use_id: row.tool_use_id ?? null, + title: row.title ?? '', hook_phase: row.hook_phase, mode: row.mode, dedup_key: row.dedup_key ?? null, diff --git a/plugins/memhub/mod/engine/tests/lanes.test.ts b/plugins/memhub/mod/engine/tests/lanes.test.ts index dc383b71..c5623947 100644 --- a/plugins/memhub/mod/engine/tests/lanes.test.ts +++ b/plugins/memhub/mod/engine/tests/lanes.test.ts @@ -20,7 +20,7 @@ import { test, expect } from 'claude-code/testing' import { postResultText } from '../../lanes' -import { createEngine } from '../engine' +import { createEngine, ForeignRepoError } from '../engine' import type { EngineIO, HookRule, StateOp, TurnMessage, Verdict } from '../index' import { applyStateOp, eventAsWritten, fireAsWritten, isEvent, modelText, sessionDoc as laneSessionDoc, sortedArgv, @@ -50,6 +50,8 @@ type Step = { prompt?: string session?: string cwd?: string + /** A call into another repo's checkout: the mod hands it to Python (ForeignRepoError). */ + handoff?: boolean } type Case = { name: string @@ -59,6 +61,8 @@ type Case = { git?: [string[], number, string][] judge?: (Dict | 'timeout')[] env?: Record + /** checkout dir name → the repo it is a checkout of (else REPO). */ + repo_names?: Record steps: Step[] } type StepOut = { @@ -152,7 +156,11 @@ function world(c: Case, o: CaseOut) { baseBranch: c.env?.MEMHUB_RULEBOOK_BASE_BRANCH, briefBudget: c.env?.MEMHUB_BRIEF_TOKEN_BUDGET, }), - paths: async (dir) => ({ repo: REPO, root: dir, base: BASE }), + paths: async (dir) => ({ repo: c.repo_names?.[dir.slice(dir.lastIndexOf('/') + 1)] ?? REPO, root: dir, base: BASE }), + getenv: async (name) => { + const v = c.env && Object.prototype.hasOwnProperty.call(c.env, name) ? c.env[name] : undefined + return v === undefined ? undefined : deepSub(v, o.root) + }, state: async (ops) => ops.map(applyOp), judge: async (body) => { s.sent.push(JSON.parse(JSON.stringify(body)) as Dict) @@ -242,6 +250,14 @@ for (const v of VECTORS) { engine.setBook(o.rules, { repo: REPO, fetchedAt: 0 }) for (const [i, raw] of c.steps.entries()) { const want = o.steps[i]! + if (raw.handoff) { + // Python judged it with the other repo's book (the vector's fires); the mod must not. + const where = `${c.name} step ${i}` + expect({ where, pythonFired: want.fires.length > 0 }).toEqual({ where, pythonFired: true }) + const threw = await runStep(engine, w, o, raw, i).then(() => null, (err: unknown) => err) + expect({ where, foreign: threw instanceof ForeignRepoError }).toEqual({ where, foreign: true }) + continue + } const { step, got, sessionId } = await runStep(engine, w, o, raw, i) const where = `${c.name} step ${i} (${step.lane}${step.tool ? ' ' + step.tool : ''})` const hso = (want.stdout?.hookSpecificOutput ?? {}) as Dict @@ -324,7 +340,7 @@ test('a Bash output persisted to a file fires on its stdout, past the preview th test('lane vectors cover every lane, a gate, a judge hold and an ordering receipt', () => { // Recorded count (vector_checks.ts): raise it when cases are added. - expect(belowRatchet('lanes', VECTORS.length, 25)).toBeNull() + expect(belowRatchet('lanes', VECTORS.length, 29)).toBeNull() const steps = VECTORS.flatMap((v) => (v.out as CaseOut).steps) const lanes = new Set(VECTORS.flatMap((v) => (v.args[0] as Case).steps.map((s) => s.lane))) expect([...lanes].sort()).toEqual(['post', 'pre', 'prompt', 'session']) @@ -360,6 +376,35 @@ test('a call in a checkout of another repo is not judged against this book', asy .rejects.toThrow('the loaded book is acme/app') }) +test('a Bash call that cds into another repo, or edits a file there, is that repo\'s: handed to Python', async () => { + const root = '/tmp/memhub-lane-vectors/foreign-cd' + const c: Case = { name: 'foreign-cd', rules: [], steps: [], files: { 'other/.git/HEAD': 'ref: refs/heads/main\n', 'a.py': '' } } + const io = world(c, { root, rules: [], git_table: [], steps: [] }).io + const engine = createEngine({ + ...io, + home: root, + getenv: async (name) => (name === 'OTHER' ? `${root}/other` : undefined), + paths: async (dir) => ({ repo: dir.endsWith('/other') ? 'acme/other' : REPO, root: dir, base: BASE }), + }) + engine.setBook([{ id: 'g', on: 'bash', mode: 'gate', rx: 'push', text: 'x' }], { repo: REPO, fetchedAt: 0 }) + const pre = (input: Record, tool = 'Bash') => + engine.pre({ phase: 'pre', tool, input, sessionId: SESSION, cwd: root, toolUseId: 'tu-f' }) + for (const command of ['cd other && git push', 'cd $OTHER && git push', `git -C ${root}/other push`, 'cd ~/x && cd ' + root + '/other; git push', + 'cd ~/other && git push']) { + await expect(pre({ command })).rejects.toThrow('call is in acme/other') + } + await expect(pre({ file_path: `${root}/other/b.py`, content: 'x' }, 'Write')).rejects.toThrow('call is in acme/other') + // no cd at all: a file the command WRITES or READS in the other checkout is that repo's to judge + for (const command of ['echo a,b > other/live_probe.csv', "tee ~/other/app/services/llm.py >/dev/null <<'PY'\nclient = AsyncOpenAI(api_key=k)\nPY", + 'tee $HOME/other/x.py < here.csv && git push' })).deny).toBeDefined() +}) + // The real engine on each tool's d.ts input shape: it reads the path and the // body from the keys rulebook_hook.py reads from `tool_input`. test('the engine reads Edit/Write/Read paths and bodies from the tool arguments, as rulebook_hook.py does', async () => { diff --git a/plugins/memhub/mod/engine/tests/redirect.test.ts b/plugins/memhub/mod/engine/tests/redirect.test.ts new file mode 100644 index 00000000..687a7b73 --- /dev/null +++ b/plugins/memhub/mod/engine/tests/redirect.test.ts @@ -0,0 +1,68 @@ +// The §4b forward-test hand-off (mod/redirect.ts): when the shell hands a call +// to the Python hook because a live claim covers it (ENG-1250). +import { test, expect } from 'claude-code/testing' + +import { REDIRECT_NAME, forwardTestCovers } from '../../redirect' +import { REDIRECT_MAX_AGE_S } from '../shell' + +const BASE = '/home/u/.config/memhub-plugin/rulebook/api.example' +const CLAIM = `${BASE}/${REDIRECT_NAME}` +const REPO = '/work/app' +const PREFIX = `${REPO}/.claude/worktrees` +const NOW = 1_790_000_000_000 + +function io(opts: { text?: string; mtimeMs?: number; real?: Record; now?: number } = {}) { + const asked: string[] = [] + return { + asked, + readText: async (p: string) => (p === CLAIM ? opts.text : undefined), + stat: async (p: string, resolve?: boolean) => { + asked.push(`${resolve ? 'real ' : ''}${p}`) + if (p === CLAIM) return opts.text === undefined ? undefined : { mtimeMs: opts.mtimeMs ?? NOW - 1000 } + const real = opts.real?.[p] + return real === undefined ? undefined : { mtimeMs: NOW, realPath: real } + }, + now: async () => opts.now ?? NOW, + } +} + +const claim = (prefix: unknown = PREFIX) => JSON.stringify({ base: '/tmp/x/pretest-base', cwd_prefix: prefix, pid: 1 }) + +test('no claim file: nothing is handed off, sub-agent or not', async () => { + const f = io() + expect(await forwardTestCovers(f, BASE, `${PREFIX}/agent-1`, undefined)).toBe(false) + expect(await forwardTestCovers(f, BASE, REPO, 'agent-1')).toBe(false) + expect(await forwardTestCovers(f, undefined, `${PREFIX}/agent-1`, 'agent-1')).toBe(false) +}) + +test('a session cwd inside the claimed prefix is handed off; the checkout above it is not', async () => { + const f = io({ text: claim() }) + expect(await forwardTestCovers(f, BASE, `${PREFIX}/agent-1`, undefined)).toBe(true) + expect(await forwardTestCovers(f, BASE, PREFIX, undefined)).toBe(true) + expect(await forwardTestCovers(f, BASE, REPO, undefined)).toBe(false) + expect(await forwardTestCovers(f, BASE, `${REPO}/.claude/worktrees-other/x`, undefined)).toBe(false) +}) + +test("a sub-agent's call is handed off while a claim is live, whatever the session cwd says", async () => { + const f = io({ text: claim() }) + expect(await forwardTestCovers(f, BASE, REPO, 'agent-1')).toBe(true) +}) + +test('the real paths must agree when both resolve: a symlink cannot pull a session in', async () => { + const f = io({ text: claim(), real: { [`${PREFIX}/agent-1`]: '/elsewhere/agent-1', [PREFIX]: '/real/worktrees' } }) + expect(await forwardTestCovers(f, BASE, `${PREFIX}/agent-1`, undefined)).toBe(false) + const g = io({ text: claim(), real: { [`${PREFIX}/agent-1`]: '/real/worktrees/agent-1', [PREFIX]: '/real/worktrees' } }) + expect(await forwardTestCovers(g, BASE, `${PREFIX}/agent-1`, undefined)).toBe(true) +}) + +test('a stale claim (older than REDIRECT_MAX_AGE_S) steers nothing', async () => { + const f = io({ text: claim(), mtimeMs: NOW - (REDIRECT_MAX_AGE_S + 5) * 1000 }) + expect(await forwardTestCovers(f, BASE, `${PREFIX}/agent-1`, 'agent-1')).toBe(false) +}) + +test('a malformed claim, or one naming no prefix, steers nothing', async () => { + expect(await forwardTestCovers(io({ text: '{not json' }), BASE, `${PREFIX}/a`, 'agent-1')).toBe(false) + expect(await forwardTestCovers(io({ text: '[1]' }), BASE, `${PREFIX}/a`, 'agent-1')).toBe(false) + expect(await forwardTestCovers(io({ text: claim('') }), BASE, `${PREFIX}/a`, 'agent-1')).toBe(false) + expect(await forwardTestCovers(io({ text: claim(7) }), BASE, `${PREFIX}/a`, 'agent-1')).toBe(false) +}) diff --git a/plugins/memhub/mod/engine/tests/shell.test.ts b/plugins/memhub/mod/engine/tests/shell.test.ts index eae6c13a..3edc7b94 100644 --- a/plugins/memhub/mod/engine/tests/shell.test.ts +++ b/plugins/memhub/mod/engine/tests/shell.test.ts @@ -5,7 +5,7 @@ import { test, expect } from 'claude-code/testing' import { VECTORS as OWN, type Vector } from './vectors/shell' import { VECTORS as CORPUS } from './vectors/shell_corpus' import { - anchorHandle, anchorHits, anchorPattern, bashReads, blankQuoted, blankSyntax, cdPrefixTarget, + anchorHandle, anchorHits, anchorPattern, bashDirs, bashReads, bashWriteTargets, callDir, expandPath, blankQuoted, blankSyntax, cdPrefixTarget, commandFires, fnmatch, headTailLines, judgeCallText, judgeMatchedOn, judgeRedact, lastSegment, logicalLines, pathInScope, redactEchoes, redactIdentities, redactSecrets, redactText, redactedTokens, redirectBase, sedLines, segmentOp, shellOnly, shlexSplit, stripComments, @@ -15,6 +15,8 @@ import { import { belowRatchet, pyKind } from './vector_checks' const HOME = '/home/vector' +/** rule_vector_cases/shell.py ENV: the environment the call/write parsers expand in. */ +const ENV = { HOME, WT: '/w/wt', EMPTY: '' } /** Python `re` flag ints as pyre letters. */ const flagLetters = (f: unknown): string => { @@ -76,7 +78,10 @@ const PORTS: Record) => unknown> = { judge_matched_on: ([rule, ev, handle, root]) => judgeMatchedOn(rule, ev, handle, root), _under: ([p, b]) => under(p, b), cd_prefix_target: ([c]) => cdPrefixTarget(c), - repo_of_call_cd: ([c], k) => (k.tool_name ?? 'Bash') === 'Bash' && cdPrefixTarget(c) !== null, + call_dir: ([cwd, c]) => callDir(cwd, c, ENV), + bash_dirs: ([cwd, c]) => bashDirs(cwd, c, ENV), + bash_write_targets: ([cwd, c]) => bashWriteTargets(cwd, c, ENV), + expand_path: ([t, here]) => expandPath(t, here, ENV), _redirect_base: ([cwd, f]: [string, Facts]) => { let data: unknown if (f.text !== null) { diff --git a/plugins/memhub/mod/lanes.ts b/plugins/memhub/mod/lanes.ts index 9e63b8e0..1f4d51eb 100644 --- a/plugins/memhub/mod/lanes.ts +++ b/plugins/memhub/mod/lanes.ts @@ -14,6 +14,12 @@ // session the posture preamble as an isMeta user row // (`$.session.append`, spec §4.5), once per session id. // +// A call in a checkout of ANOTHER repo than the mod's book (engine.ts +// ForeignRepoError: an edit there, or `cd ~/other && …`) is not a failure: +// both halves of it go to Python, which loads that repo's book — the pre and +// post lanes leave the claim around `next` and come back after it, and no +// failure is counted, so a session that works across repos keeps its claim. +// // Failure: a lane that throws gives the call to Python. The pre and prompt // lanes leave the claim before their `next`, so the Python hook beneath serves // that very call; the post lane has run past Python's PostToolUse by then (a @@ -28,6 +34,7 @@ import type { Act } from './act' import type { Claims } from './claims' import type { Io } from './ctx' import type { CallEvent, Engine, Verdict } from './engine' +import { ForeignRepoError } from './engine/engine' export const TOOLS = ['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Read'] as const /** @@ -64,21 +71,42 @@ const RESULT_KEYS = ['stderr', 'stdout', 'output', 'error', 'text'] as const * `tool_response.stdout`) still holds the first ~30,000 characters. A rule * matching past the preview fired in Python and not here. * - * Falls back to `r.text` for every other tool, for a record that is not an - * object (an error result is the error string), and for a record with no - * non-empty field: Python would match `json.dumps(tool_response)` there, - * which the mod does not reproduce (engine.ts, port notes). + * A FAILED Bash call's record is the error string itself (`Error: Exit code + * 1\n`): it stands in for a missing `text`, and either is shaped as rulebook_hook.py `failure_result` + * shapes the PostToolUseFailure `error` — the `Exit code N` line moved last, + * so a content rule reads the program's own words first (ENG-1236). The + * Python failure lane is claim-skipped while this mod serves `post`, so this + * is the only engine that sees the failure. + * + * Falls back to `r.text` for every other tool, for a record that is neither + * an object nor a string, and for a record with no non-empty field: Python + * would match `json.dumps(tool_response)` there, which the mod does not + * reproduce (engine.ts, port notes). */ -export function postResultText(tool: string, r: { text?: string; result?: unknown }): string | undefined { +export function postResultText(tool: string, r: { text?: string; result?: unknown; isError?: boolean }): string | undefined { if (tool !== 'Bash') return r.text const rec = r.result - if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return r.text + if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) { + const text = r.text || (typeof rec === 'string' ? rec : undefined) + return text && r.isError ? failureText(text) : text + } const parts = RESULT_KEYS .map(k => (rec as Record)[k]) .filter((v): v is string => typeof v === 'string' && v !== '') return parts.length ? parts.join('\n') : r.text } +const EXIT_LINE = /^\s*(?:Error: )?Exit code (\d+)[ \t]*\n?/ + +/** `Error: Exit code 1\nboom` → `boom\nExit code 1`: the program's words first, + * the exit line last, as rulebook_hook.py `failure_result` shapes it. */ +export function failureText(text: string): string { + const m = EXIT_LINE.exec(text) + if (!m) return text + const body = text.slice(m[0].length) + return body ? `${body.replace(/\n$/, '')}\nExit code ${m[1]}` : `Exit code ${m[1]}` +} + /** The tools whose rules scope by path, and the input fields the engine * reads the path from (engine.ts `file_path`, repo.ts `notebook_path`). */ const PATH_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit', 'Read'] @@ -95,6 +123,12 @@ export class Lanes { private engine: Engine, private claims: Claims, private act: Act, + /** + * A create-rule forward-test claim covers this call (mod/redirect.ts): + * hand it to the Python hook, which serves the test's private book. The + * shell wires it; a fake without it never hands off on that account. + */ + private forwardTest?: (cwd: string, agentId: string | undefined) => Promise, ) {} // ── tool.call ─────────────────────────────────────────────────────────── @@ -139,14 +173,29 @@ export class Lanes { let pre: Verdict | undefined let handed: R | undefined + let foreign = false if (this.claims.has('pre')) { try { + // A forward test's call (ENG-1250): the candidate it must prove lives + // only in the test's private book, which the mod never serves + // (book.ts `hookRuleFor`). Out of the claim for pre AND post, as for a + // foreign repo, so Python reads the claim and judges it on that book. + if (this.forwardTest && (await this.forwardTest(cwd, agentId).catch(() => false))) { + throw new ForeignRepoError(`a forward-test claim over ${cwd}`, "the test's private book, which Python serves") + } pre = await this.engine.pre({ ...base, phase: 'pre' }) this.act.healthy('pre') } catch (err) { - // Python's hook serves this call's pre at `next`; the post lane below - // still runs here, as it would have without the failure. - handed = await this.handOff('pre', err, () => next(e)) + if (err instanceof ForeignRepoError) { + // Another repo's call: Python's hooks serve its pre AND post with + // that repo's book, and the mod's post lane below stays out. + foreign = true + handed = await this.handForeign(() => next(e)) + } else { + // Python's hook serves this call's pre at `next`; the post lane below + // still runs here, as it would have without the failure. + handed = await this.handOff('pre', err, () => next(e)) + } } if (pre) { await this.act.record(pre, sessionId) @@ -158,7 +207,7 @@ export class Lanes { if (r.deny !== undefined) return r let post: Verdict | undefined - if (this.claims.has('post')) { + if (this.claims.has('post') && !foreign) { try { post = await this.engine.post({ ...base, phase: 'post', result: { text: postResultText(tool, r), isError: r.isError } }) this.act.healthy('post') @@ -247,6 +296,16 @@ export class Lanes { } } + /** A foreign-repo call: pre and post out of the claim around `next`, no failure counted. */ + private async handForeign(next: () => Promise): Promise { + const lanes = await this.claims.yieldCall(['pre', 'post']).catch(() => [] as LaneName[]) + try { + return await next() + } finally { + for (const l of lanes) await this.claims.recover(l).catch(() => undefined) + } + } + /** * A lane threw before its `next`: out of the claim first, so the Python * hook the engine starts at `next` serves this call; back in afterwards diff --git a/plugins/memhub/mod/redirect.ts b/plugins/memhub/mod/redirect.ts new file mode 100644 index 00000000..df09e6af --- /dev/null +++ b/plugins/memhub/mod/redirect.ts @@ -0,0 +1,75 @@ +// The §4b forward test's claim, as the shell honours it: a call it covers is +// handed to the Python hook (lanes.ts `handForeign`), which reads the claim in +// full (`rulebook_hook._redirect_base`) and serves the test's private book. +// +// /memhub:create-rule step 4b proves a candidate rule fires before filing it: +// it copies the rulebook state to a private base, adds the candidate as a +// `candidate-…` row, writes `/pretest-redirect.json` naming that +// base and the directory prefix it covers (`/.claude/worktrees`), and +// runs the violating command in an Agent-tool fork isolated in a worktree +// there. The mod drops candidate rows (book.ts `hookRuleFor`, activeBase +// false) and its lane claim is keyed on the session id, which a fork shares +// with its parent — so with no hand-off the fork's calls were judged on the +// real book, nothing fired, and the skill refused to file (ENG-1250). +// +// This decides only whether to hand off; Python decides what the claim means. +// The uid, mode and `base` checks are Python's: a call handed over under a +// claim Python rejects is served by Python from the real base, which is the +// same answer the mod would have given. So every doubt here reads as "hand +// off" only where the claim is plainly live, and as "serve" otherwise. + +import { REDIRECT_MAX_AGE_S, abspath, normpath, under } from './engine/shell' + +export const REDIRECT_NAME = 'pretest-redirect.json' + +/** What the check reads: the file's text, its stat, the clock. */ +export type RedirectIo = { + /** Resolves undefined when the file is missing or unreadable. */ + readText(path: string): Promise + /** `realPath` with `resolve`; undefined when the path does not exist. */ + stat(path: string, resolve?: boolean): Promise<{ mtimeMs: number; realPath?: string } | undefined> + /** Epoch milliseconds. */ + now(): Promise +} + +/** + * True when a live forward-test claim under `realBase` should steer this call + * to the Python hook: + * + * - the session's cwd is inside the claimed prefix (the harness's shape: a + * session started in the test worktree), checked on the spelling and, where + * both resolve, on the real paths, as Python checks them; or + * - the call is a sub-agent's (`agentId`): its own cwd is the isolated + * worktree, which the shell's `session.cwd()` does not report, so Python, + * which reads the call's cwd off the hook payload, is the one that can tell. + * + * Never when there is no claim file, when it is older than REDIRECT_MAX_AGE_S + * (a forgotten one from an interrupted run), or when it names no prefix. + */ +export async function forwardTestCovers( + io: RedirectIo, realBase: string | undefined, cwd: string, agentId: string | undefined, +): Promise { + if (!realBase) return false + const path = `${realBase}/${REDIRECT_NAME}` + try { + const text = await io.readText(path) + if (text === undefined) return false + const st = await io.stat(path) + if (!st || (await io.now()) - st.mtimeMs > REDIRECT_MAX_AGE_S * 1000) return false + const data: unknown = JSON.parse(text) + if (!data || typeof data !== 'object' || Array.isArray(data)) return false + const prefix = (data as Record).cwd_prefix + if (typeof prefix !== 'string' || !prefix) return false + if (cwd) { + const here = normpath(abspath(cwd)) + const pre = normpath(abspath(prefix)) + if (under(here, pre)) { + const [rh, rp] = await Promise.all([io.stat(here, true), io.stat(pre, true)]) + if (!rh?.realPath || !rp?.realPath || under(rh.realPath, rp.realPath)) return true + } + } + return agentId !== undefined + } catch { + return false + } +} diff --git a/plugins/memhub/mod/register.ts b/plugins/memhub/mod/register.ts index d831eb9a..51bb3478 100644 --- a/plugins/memhub/mod/register.ts +++ b/plugins/memhub/mod/register.ts @@ -12,20 +12,22 @@ // `prompt.compose`, `skill.prompt` or `attribution.text` (spec §1.4, D3): the // built-in guard skips user mods on those for most Team/Enterprise sign-ins. -import type { EngineInterface, On } from 'claude-code' +import type { EngineInterface, On, PluginOptions } from 'claude-code' import type { FireNote, LaneName } from '../types' -// The companion registers last (innermost) with the mod's ctx: it reads fires -// from `$.state` and polls proposals over `$.http.fetch` (`register(on, ctx)`). +// The companion registers last (innermost) with the mod's ctx and the +// plugin's userConfig options (its `companion` switch): it reads fires from +// `$.state` and polls proposals over `$.http.fetch` (`register(on, ctx, options)`). import { register as registerCompanion } from '../companion/register' import { Act } from './act' import { Book, RemoteSwitch } from './book' import { Claims, StatusLine } from './claims' -import { type Ctx, type Env, envOf, type Io, lastJson, makeCtx, MOD_CLI, rootOf } from './ctx' +import { ACTIVE, type Active, type Ctx, type Env, envOf, type Io, lastJson, makeCtx, MOD_CLI, rootOf } from './ctx' import type { Engine, EngineEnv, EngineIO, FileStat, HookRule, PathsInfo, TurnMessage } from './engine' import { createEngine } from './engine/engine' import { Lanes, TOOL_RX } from './lanes' +import { forwardTestCovers } from './redirect' /** * The Ctx every part receives at register time. Its fields are the real @@ -44,6 +46,12 @@ function lateCtx(): Ctx & { bind(real: Ctx): void } { }, api: () => (real ? real.api() : Promise.resolve(undefined)), forgetApi: () => real?.forgetApi(), + // Before the shell is bound nothing can ask the allowlist: active, as always. + active: () => (real ? real.active() : Promise.resolve(ACTIVE)), + setActive: value => real?.setActive(value), + book: () => real?.book(), + setBook: info => real?.setBook(info), + signIn: () => real?.signIn() ?? 'unknown', bind(r) { real = r }, @@ -164,6 +172,7 @@ const COUNT_LINES_PY = /** The engine's own I/O, over the shell's port (and `$`, through `extras`). */ function engineIoOf(io: Io, ctx: Ctx, extras: EngineExtras | undefined, home: string): EngineIO { const pathsCache = new Map>() + const envCache = new Map>() return { git: async (argv, cwd, timeoutMs = 10_000) => { const r = await io.run(['git', ...argv], { cwd, timeoutMs }) @@ -186,6 +195,20 @@ function engineIoOf(io: Io, ctx: Ctx, extras: EngineExtras | undefined, home: st tzOffsetMinutes: ms => -new Date(ms).getTimezoneOffset(), home, env: () => (extras ? extras.env() : Promise.resolve({})), + // `$.env.get` takes only a literal name, so a variable a Bash command + // names (`cd "$WT"`) is read the way the Python hook inherits it: from + // this process's environment, once per name per session. + getenv: name => { + let v = envCache.get(name) + if (!v) { + v = /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) + ? io.run(['printenv', name], { timeoutMs: 2_000 }) + .then(r => (r.exitCode === 0 ? r.stdout.replace(/\n$/, '') : undefined), () => undefined) + : Promise.resolve(undefined) + envCache.set(name, v) + } + return v + }, paths: dir => { let p = pathsCache.get(dir) if (!p) { @@ -321,17 +344,40 @@ export async function boot( let isLoaded = false if (!makeEngine) { await claims.failLoad() + ctx.setActive(ACTIVE) // nothing asked the allowlist: the companion shows as always const lanes = new Lanes(io, NO_ENGINE, claims, act) return { io, ctx, claims, act, lanes, book: undefined, ready: Promise.resolve(), firstPrompt: async () => {}, sessionChanged: async () => {} } } const home = opts.extras ? await opts.extras.home().catch(() => '') : '' const engine = makeEngine(engineIoOf(io, ctx, opts.extras, home)) const hookVersion = await versionOf(io) - const book = new Book(io, ctx, engine, claims, status, { hookVersion, toHook: opts.toHook }) + // The repository allowlist's answer for the session's cwd, as `paths` + // gives it with the book's location: dormant in a repository repos.json + // leaves out, and outside any checkout while a repos.json exists — in + // both no book is loaded and no lane claimed. The companion reads it + // through ctx.active(); it is settled below whatever the load came to, so + // nothing waits on it forever. + let active: Active = ACTIVE + const book = new Book(io, ctx, engine, claims, status, { + hookVersion, + toHook: opts.toHook, + onPaths: a => { active = { enabled: a.enabled, repo: a.repo, restricted: a.restricted, declined: a.declined, identity: a.identity } }, + // what the companion's status strip says when nothing fired and nothing waits + onRules: (repo, rules) => ctx.setBook({ repo, rules }), + }) // The remote switch (book.ts): a false gives every lane to Python for the // session, and stops the book's refresh, which then serves nothing. const remote = new RemoteSwitch(io, ctx, claims, status, { hookVersion, onOff: () => book.stop() }) - const lanes = new Lanes(io, engine, claims, act) + // A create-rule forward test's claim (mod/redirect.ts): the call goes to + // Python, which serves the test's private book. Read per call from the + // install's own base; without `extras` (the tests) no claim can be stat'd + // and nothing is handed off on that account. + const redirectIo = { + readText: (p: string) => io.readFile(p).catch(() => undefined), + stat: (p: string, resolve?: boolean) => (opts.extras ? opts.extras.stat(p, resolve) : Promise.resolve(undefined)), + now: () => io.now(), + } + const lanes = new Lanes(io, engine, claims, act, (cwd, agentId) => forwardTestCovers(redirectIo, book.realBase, cwd, agentId)) // Claim late, in the background: the first prompt never waits on the book, // and until it is held Python serves every lane. const ready = (async () => { @@ -348,6 +394,8 @@ export async function boot( } catch (err) { act.unhealthy('load', err) await claims.failLoad().catch(() => undefined) + } finally { + ctx.setActive(active) } })() return { @@ -379,7 +427,8 @@ const NO_ENGINE: Engine = { session: () => Promise.reject(new Error('no engine')), } -export function register(on: On) { +/** The hooks module's entry (`Register`): `options` are the manifest's userConfig values, defaults filled in. */ +export function register(on: On, options: PluginOptions = {}) { const ctx = lateCtx() let shell: Shell | undefined @@ -397,6 +446,15 @@ export function register(on: On) { // A fresh process has no `lanes` in $.state; a hot reload keeps it. const pastStartup = (await io.getState('lanes')).version > 0 shell = await boot(io, real, createEngine, { cwd: e.cwd, pastStartup, extras: extrasOf($) }) + // The companion's status strip reads the book and the allowlist off + // ctx once the load has settled: draw the band again then. + void shell.ready.then(() => { + try { + $.ui.invalidate('ui.render') + } catch { + // a band nothing draws + } + }) } } catch { shell = undefined @@ -465,5 +523,5 @@ export function register(on: On) { }).catch(($, e, next) => next(e)) // ── companion: last, so innermost ───────────────────────────────────────── - registerCompanion(on, ctx) + registerCompanion(on, ctx, options) } diff --git a/plugins/memhub/mod/tests/book.test.ts b/plugins/memhub/mod/tests/book.test.ts index 9b1bb3be..4a1e91fb 100644 --- a/plugins/memhub/mod/tests/book.test.ts +++ b/plugins/memhub/mod/tests/book.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from 'claude-code/testing' import { + askPaths, Book, FETCH_REFUSED, isFetchRefused, @@ -12,6 +13,7 @@ import { isSecure, msOfIso, parseBook, + type PathsAnswer, quoteAll, REFRESH_GAVE_UP, REFRESH_MS, @@ -19,7 +21,7 @@ import { shapeRules, } from '../book' import { Claims, StatusLine } from '../claims' -import { BOOK, fakeCtx, fakeEngine, type FakeIo, fakeIo, REPO, reply, ROOT, toHook } from './fakes' +import { BOOK, defaultRun, fakeCtx, fakeEngine, type FakeIo, fakeIo, ok, REPO, reply, ROOT, toHook } from './fakes' const CACHED = JSON.stringify({ etag: '"v1"', fetched_at: '2026-10-06T00:00:00.000000+00:00', rules: [{ id: 'r1' }] }) const SERVED = { code: 0, msg: 'ok', data: { rules: [{ id: 'r1' }, { id: 'r2' }, { id: 'r2' }, { nope: true }] } } @@ -61,6 +63,64 @@ describe('load', () => { const book = new Book(io, fakeCtx(io), fakeEngine(), new Claims(io, 'staging', status), status, { toHook }) expect(await book.load('/work')).toBe(false) }) + + test('a repository the allowlist leaves out (`paths` says enabled false): nothing loaded, nothing claimed, and the shell is told', async () => { + const io = fakeIo({ + onRun: argv => (argv[2] === 'paths' + ? ok(JSON.stringify({ book: BOOK, repo: REPO, ledger: '/l', enabled: false, restricted: true })) + : defaultRun(argv)), + }) + io.files.set(BOOK, CACHED) + const engine = fakeEngine() + const status = new StatusLine(io) + const told: [boolean, string][] = [] + const book = new Book(io, fakeCtx(io), engine, new Claims(io, 'staging', status), status, { + toHook, onPaths: a => told.push([a.enabled, a.repo]), + }) + expect(await book.load('/work')).toBe(false) + expect(told).toEqual([[false, REPO]]) + expect(engine.books).toEqual([]) // the cached book is there, and not handed over + expect(io.fetches).toHaveLength(0) // and not refreshed + expect(io.envWrites).toEqual([]) // no lane claimed: Python serves, and stays silent there too + }) + + test('the ask\'s fields ride along (ENG-1253): restricted, declined and the identity repos.json names, read off `paths`', async () => { + const io = fakeIo({ + onRun: argv => (argv[2] === 'paths' + ? ok(JSON.stringify({ book: BOOK, repo: REPO, ledger: '/l', enabled: false, restricted: true, declined: true, identity: 'Acme/demo' })) + : defaultRun(argv)), + }) + const status = new StatusLine(io) + const told: PathsAnswer[] = [] + const book = new Book(io, fakeCtx(io), fakeEngine(), new Claims(io, 'staging', status), status, { + toHook, onPaths: a => told.push(a), + }) + expect(await book.load('/work')).toBe(false) + expect(told.map(a => [a.enabled, a.restricted, a.declined, a.identity])).toEqual([[false, true, true, 'Acme/demo']]) + // an older CLI answers none of them: nothing to ask about + const older = await askPaths(fakeIo(), fakeCtx(fakeIo()), '/work') + expect([older.restricted, older.declined, older.identity]).toEqual([false, false, '']) + }) + + test('an older CLI with no `enabled` in its answer: active, as before the list existed', async () => { + const { engine } = await loaded() + expect(engine.books).toHaveLength(1) + }) + + test('outside any checkout with a repos.json: no book, and the shell is still told it is dormant there', async () => { + const io = fakeIo({ + onRun: argv => (argv[2] === 'paths' + ? ok(JSON.stringify({ book: null, repo: '', ledger: '/l', enabled: false, restricted: true })) + : defaultRun(argv)), + }) + const status = new StatusLine(io) + const told: [boolean, string][] = [] + const book = new Book(io, fakeCtx(io), fakeEngine(), new Claims(io, 'staging', status), status, { + toHook, onPaths: a => told.push([a.enabled, a.repo]), + }) + expect(await book.load('/parent-of-repos')).toBe(false) + expect(told).toEqual([[false, '']]) + }) }) describe('refresh', () => { diff --git a/plugins/memhub/mod/tests/ctx.test.ts b/plugins/memhub/mod/tests/ctx.test.ts index 1ff893a5..fb701a03 100644 --- a/plugins/memhub/mod/tests/ctx.test.ts +++ b/plugins/memhub/mod/tests/ctx.test.ts @@ -36,6 +36,53 @@ describe('makeCtx', () => { expect(io.runs).toHaveLength(2) }) + test('signIn: in with a credential, out for api-info\'s {}, unknown when it cannot answer', async () => { + const answers: [number, string][] = [ + [0, JSON.stringify({ base: 'https://api.example.test', bearer: 'tok' })], + [0, '{}'], + [1, JSON.stringify({ error: 'ModuleNotFoundError: x' })], + [0, 'not json'], + ] + const want = ['in', 'out', 'unknown', 'unknown'] + for (const [i, [exitCode, stdout]] of answers.entries()) { + const io = fakeIo({ onRun: () => ({ ...ok(stdout), exitCode }) }) + const ctx = makeCtx(io, 'staging') + expect(ctx.signIn(), 'before any lookup').toBe('unknown') + await ctx.api() + expect(ctx.signIn()).toBe(want[i]) + } + }) + + test('signIn: a lookup overtaken by a forgetApi() and a newer one does not overwrite its answer', async () => { + const gates: ((stdout: string) => void)[] = [] + const io = fakeIo({ onRun: () => new Promise(resolve => gates.push(stdout => resolve(ok(stdout)))) }) + const ctx = makeCtx(io, 'staging') + const first = ctx.api() + ctx.forgetApi() + const second = ctx.api() + await new Promise(r => setTimeout(r, 0)) + gates[1]!(JSON.stringify({ base: 'https://api.example.test', bearer: 'tok' })) + await second + gates[0]!('{}') + await first + expect(ctx.signIn()).toBe('in') + }) + + test('active(): waits for the first setActive, then answers the latest (the strip\'s Yes sets it again)', async () => { + const ctx = makeCtx(fakeIo(), 'staging') + let settled = false + const first = ctx.active().then(a => { + settled = true + return a + }) + await Promise.resolve() + expect(settled, 'nothing has answered yet').toBe(false) + ctx.setActive({ enabled: false, repo: 'app', restricted: true, declined: false, identity: 'Acme/app' }) + expect(await first).toEqual({ enabled: false, repo: 'app', restricted: true, declined: false, identity: 'Acme/app' }) + ctx.setActive({ enabled: true, repo: 'app', restricted: true, declined: false, identity: 'Acme/app' }) + expect((await ctx.active()).enabled, 'the latest answer').toBe(true) + }) + test('the prod export renames the plugin, and the env follows the name', () => { expect(envOf('memhub-staging')).toBe('staging') expect(envOf('memhub')).toBe('prod') diff --git a/plugins/memhub/mod/tests/fakes.ts b/plugins/memhub/mod/tests/fakes.ts index f4f9354d..e6e01e36 100644 --- a/plugins/memhub/mod/tests/fakes.ts +++ b/plugins/memhub/mod/tests/fakes.ts @@ -148,6 +148,11 @@ export function fakeCtx(io: Io, env: Env = 'staging'): Ctx { root: io.root, api: async () => ({ base: 'https://api.example.test', bearer: 'tok' }), forgetApi() {}, + active: async () => ({ enabled: true, repo: REPO }), + setActive() {}, + book: () => undefined, + setBook() {}, + signIn: () => 'in', } } diff --git a/plugins/memhub/mod/tests/lanes.test.ts b/plugins/memhub/mod/tests/lanes.test.ts index f52bcac2..83157311 100644 --- a/plugins/memhub/mod/tests/lanes.test.ts +++ b/plugins/memhub/mod/tests/lanes.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from 'claude-code/testing' +import { ForeignRepoError } from '../engine/engine' import { boot } from '../register' import { BOOK, EMPTY, fakeCtx, fakeEngine, fakeIo, fire, toHook } from './fakes' @@ -124,7 +125,24 @@ describe('tool.call', () => { for (const [tool, answered] of calls) { await shell.lanes.toolCall({ tool, command: 'x', file_path: '/w/x' }, async () => answered as { text: string }) } - expect(engine.calls.post.map(c => c.result!.text)).toEqual(['Error: Exit code 1\nboom', '(No output)', 'odd record', 'edited']) + expect(engine.calls.post.map(c => c.result!.text)).toEqual(['boom\nExit code 1', '(No output)', 'odd record', 'edited']) + }) + + test('a failed Bash call: the error string is the text, exit line last, as the Python failure lane shapes it (ENG-1236)', async () => { + const { engine, shell } = await ready() + // What Claude Code answers for a command that exited non-zero: the record + // is the error string; there may be no `text` at all. + const answered = { result: "Error: Exit code 1\nTypeError: greet() got an unexpected keyword argument 'name'", isError: true as const } + await shell.lanes.toolCall({ tool: 'Bash', command: 'sh probe.sh' }, async () => answered) + expect(engine.calls.post[0]!.result).toEqual({ + text: "TypeError: greet() got an unexpected keyword argument 'name'\nExit code 1", isError: true, + }) + // A successful string record is the text as it came; a `text` beside the + // record still wins, shaped the same way. + await shell.lanes.toolCall({ tool: 'Bash', command: 'true' }, async () => ({ result: 'done' })) + expect(engine.calls.post[1]!.result).toEqual({ text: 'done', isError: undefined }) + await shell.lanes.toolCall({ tool: 'Bash', command: 'x' }, async () => ({ result: 'boom', text: 'Exit code 2\n1 failed', isError: true as const })) + expect(engine.calls.post[2]!.result).toEqual({ text: '1 failed\nExit code 2', isError: true }) }) test('nothing fired: the result comes back as next answered it', async () => { @@ -284,6 +302,33 @@ describe('a pre hand-off', () => { expect(io.logs).toEqual([advice.line]) }) + test('a call in ANOTHER repo goes to Python whole — pre and post out of the claim during next — and is no failure', async () => { + const { engine, shell, io } = await ready() + engine.verdicts.pre = new ForeignRepoError('acme/other', 'acme/app') + engine.verdicts.post = { context: ['post advice'], fires: [fire('x')], ledger: [] } + const during: (string | undefined)[] = [] + for (let i = 0; i < 3; i++) { + const r = await shell.lanes.toolCall({ tool: 'Bash', command: 'cd ~/other && git push' }, async () => { + during.push(io.envVars.staging) + return { result: 'ok', text: 'ok' } + }) + // the mod's post lane stays out: Python's PostToolUse served it with the other repo's book + expect(r).toEqual({ result: 'ok', text: 'ok' }) + } + expect(engine.calls.post).toHaveLength(0) + // while next ran, the variable listed neither pre nor post, so the Python hooks served the call + expect(during).toHaveLength(3) + for (const v of during) { + const lanes = ((v ?? '').split(':')[3] ?? '').split(',') + expect(lanes.includes('pre') || lanes.includes('post')).toBe(false) + } + // three foreign calls in a row cost nothing: both lanes are still the mod's (a failure would release them at 2) + expect(shell.claims.has('pre') && shell.claims.has('post')).toBe(true) + engine.verdicts.pre = { context: [], fires: [], ledger: [] } + await shell.lanes.toolCall({ tool: 'Bash', command: 'ls' }, async () => ({ result: 'ok', text: 'ok' })) + expect(engine.calls.post).toHaveLength(1) + }) + test('a hand-off whose claims write fails still reaches next exactly once', async () => { const { engine, shell, io } = await ready() engine.verdicts.pre = new Error('engine blew up') diff --git a/plugins/memhub/plugin.json b/plugins/memhub/plugin.json index 006f2ed6..d1c9f825 100644 --- a/plugins/memhub/plugin.json +++ b/plugins/memhub/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "memhub", - "description": "XTrace MemHub team memory: search_memory / save_artifact / import_conversation MCP tools plus skills for setup, login, onboarding, artifact upload, session import, team-memory recall, session handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook \u2014 tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions, each filed with its categories and how many past sessions it would have fired in (start-rulebook), and PR babysitting. A rule is filed into a scope \u2014 everyone in the organisation (the default), just you, or a workspace you name \u2014 and reaches exactly those people. Session auto-capture ships through the host-native plugin layer (hooks are outside the Agent Plugins spec). Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (map your own unlinked PRs, or the ones you name, to the sessions on this machine that wrote their code: scan local session history once, rank the candidates per PR by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point. A companion animal draws what the plugin is doing in the band above the prompt, one per session picked from its session id (/goose, /hippo, /penguin and /shiba switch it for the session; `always` pins one for every session): asleep while the session is idle, awake and watching while Claude answers or the person types, and up announcing each Rulebook rule that fires \u2014 crossly when one blocked the call \u2014 or presenting a rule the harness just proposed with Activate / Reject / Later buttons (activating takes an org admin, or you for a rule that applies to just you; the animal says so to anyone else). Clicking the \u2665 beside it pets it, and a proposal's rule\u2197 opens that rule in MemHub Studio. It is a Claude Code mod, and mods are on by default from Claude Code 2.1.287 (the desktop Code tab from 2.1.286), so it loads with the plugin with nothing to turn on — the old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS variable is ignored; disableAllHooks, --safe-mode, an organization policy or Anthropic switching mods off remotely stops it. /memhub:companion checks that and shows or hides it.", - "version": "0.124.0", + "description": "MemHub team memory for coding agents. Your sessions are saved to your memory automatically, your team keeps a shared brain of specs and docs, team rules warn or block the agent at the moment they apply (Claude Code and Codex; shell commands and file reads on Cursor), and MCP tools let the agent search and save team memory.", + "version": "0.133.0", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/references/repo-brain.md b/plugins/memhub/references/repo-brain.md index c86242b8..391b6142 100644 --- a/plugins/memhub/references/repo-brain.md +++ b/plugins/memhub/references/repo-brain.md @@ -120,8 +120,8 @@ Creating a brain is the last resort, never the first move. 3. *Optional*, when a person is in the loop to judge a near-match: run `list_agent_brains(query="…")` with the repo or topic in natural language — it ranks brains, and an existing one may hold this subject under a - different name. The skills that resolve a repo room (onboard, pr-babysit) - skip it: the exact name is the dedup that matters. + different name. The skill that resolves a repo room (onboard) skips it: + the exact name is the dedup that matters. 4. No exact match: `create_agent_brain` with `name: "Repo: /"`, `category: "repo"`, a description (§5), and **`repo: "/"`** — the §1 name without the `Repo: ` prefix. Omit `workspace_id`. `repo` ties diff --git a/plugins/memhub/scripts/_memhub_auth.py b/plugins/memhub/scripts/_memhub_auth.py index 34f6e195..17669c52 100644 --- a/plugins/memhub/scripts/_memhub_auth.py +++ b/plugins/memhub/scripts/_memhub_auth.py @@ -265,7 +265,10 @@ async def redirect_handler(self, auth_url: str) -> None: raise NonInteractiveAuthRequired( "no cached OAuth token and interactive auth is disabled" ) - print(f"Opening browser to authenticate (same flow as /mcp)...\n {auth_url}") + # flush: when stdout is a file or pipe (a backgrounded /memhub:login), + # an unflushed link sits in the buffer until the 5-minute wait ends. + print(f"Opening browser to authenticate (same flow as /mcp)...\n {auth_url}", + flush=True) webbrowser.open(auth_url) async def authorize(self, www_authenticate: str | None = None) -> str: @@ -494,7 +497,8 @@ def _auth_token_endpoint() -> str | None: # and its answer decides where a long-lived refresh token gets POSTed. if urlparse(meta_url).scheme != "https": return None - with urllib.request.urlopen(meta_url, timeout=10) as resp: + import _tls # noqa: PLC0415 — stdlib, beside this file + with _tls.urlopen(meta_url, timeout=10) as resp: endpoint = json.loads(resp.read()).get("token_endpoint") if not isinstance(endpoint, str) or not endpoint: return None @@ -598,8 +602,9 @@ def _refresh_cached_token_if_stale(url: str) -> None: token_endpoint, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) + import _tls # noqa: PLC0415 — stdlib, beside this file try: - with urllib.request.urlopen(req, timeout=15) as resp: + with _tls.urlopen(req, timeout=15) as resp: if resp.status != 200: return fresh = json.loads(resp.read()) diff --git a/plugins/memhub/scripts/_tls.py b/plugins/memhub/scripts/_tls.py new file mode 100644 index 00000000..7a46dfb2 --- /dev/null +++ b/plugins/memhub/scripts/_tls.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""HTTPS trust for the plugin's stdlib transport — stdlib only. + +**Why this exists.** A python.org build of Python on macOS ships with an EMPTY +OpenSSL trust store until its "Install Certificates.command" has been run, and +nothing in a plain ``python3`` invocation says so. Every HTTPS call the plugin +makes then fails with ``certificate verify failed: unable to get local issuer +certificate`` — while curl, git and the browser on the same machine work, +because they read the system keychain. It first bit on a teammate's first +install: the plugin installed cleanly and the sign-in step died on the spot. +The same shape appears behind a corporate TLS proxy whose root was added to +the keychain but never to Python's bundle. + +**What it does.** Verification is never relaxed. The default context — +Python's own bundle, with ``SSL_CERT_FILE`` / ``SSL_CERT_DIR`` honoured as +always — is used first. Only when that store holds no roots at all, or a +connection fails on CERTIFICATE VERIFICATION (a handshake-time failure: no +request bytes have been sent, so retrying is safe even for a POST), is the +context augmented with roots the machine already trusts and the call retried +once: ``certifi`` when installed, the OS bundle on Linux, and on macOS the +keychain roots exported by ``security find-certificate`` (system roots, +admin-added roots and the login keychain, so a corporate root works too). +The outcome is remembered for the rest of the process, so a hook pays the +probe at most once — and on a healthy machine pays nothing, not even the +``security`` subprocess. + +Every network call in the plugin goes through here: ``mcp_http`` builds its +redirect-refusing opener with :class:`Opener`, and the few bare ``urlopen`` +sites call :func:`urlopen`. + +Run the self-test: python3 tests/tls_test.py +""" +from __future__ import annotations + +import os +import socket +import ssl +import subprocess +import sys +import urllib.request + +# Bundles a distro installs. Each that exists is loaded; duplicates are harmless. +_LINUX_BUNDLES = ( + "/etc/ssl/certs/ca-certificates.crt", # Debian / Ubuntu / Alpine + "/etc/pki/tls/certs/ca-bundle.crt", # RHEL / Fedora + "/etc/ssl/ca-bundle.pem", # openSUSE + "/etc/ssl/cert.pem", # macOS /usr/bin/python3, the BSDs +) + +# macOS keychains whose certificates `security` exports without a prompt. +# Certificates are public, so reading them never touches the keychain's secrets. +_MAC_KEYCHAINS = ( + "/System/Library/Keychains/SystemRootCertificates.keychain", + "/Library/Keychains/System.keychain", + os.path.expanduser("~/Library/Keychains/login.keychain-db"), +) + +_default_ctx: ssl.SSLContext | None = None +_augmented_ctx: ssl.SSLContext | None = None +_augmented_tried = False +_prefer_augmented = False # flipped once the default store failed and the extra roots cured it + + +def default_context() -> ssl.SSLContext: + """Python's own trust: ``create_default_context()``, built once.""" + global _default_ctx + if _default_ctx is None: + _default_ctx = ssl.create_default_context() + return _default_ctx + + +def extra_roots() -> list[tuple[str, dict]]: + """Trust roots beyond Python's bundle, as ``(label, load_verify_locations kwargs)``. + + Only sources the machine already trusts for everything else — never a root + the plugin ships — so this cannot widen what the user trusts.""" + roots: list[tuple[str, dict]] = [] + try: + import certifi # noqa: PLC0415 — optional; present with pip/httpx, absent on a bare python + roots.append(("certifi", {"cafile": certifi.where()})) + except Exception: # noqa: BLE001 — absent or broken: the next source may do + pass + for path in _LINUX_BUNDLES: + if os.path.isfile(path): + roots.append((path, {"cafile": path})) + if sys.platform == "darwin": + for keychain in _MAC_KEYCHAINS: + if not os.path.exists(keychain): + continue + try: + out = subprocess.run( + ["security", "find-certificate", "-a", "-p", keychain], + capture_output=True, text=True, timeout=10, check=False).stdout + except Exception: # noqa: BLE001 — no `security`, or it hung: skip this keychain + continue + if "BEGIN CERTIFICATE" in out: + roots.append((keychain, {"cadata": out})) + return roots + + +def augmented_context() -> ssl.SSLContext | None: + """The default context plus every extra root that loads; ``None`` when none did. + + Built at most once per process: the keychain export is the only cost here, + and it is only ever paid after the default store has already failed.""" + global _augmented_ctx, _augmented_tried + if not _augmented_tried: + _augmented_tried = True + ctx = ssl.create_default_context() + before = ctx.cert_store_stats()["x509"] + for _label, kwargs in extra_roots(): + try: + ctx.load_verify_locations(**kwargs) + except Exception: # noqa: BLE001 — one unparsable source must not sink the rest + continue + if ctx.cert_store_stats()["x509"] > before: + _augmented_ctx = ctx + return _augmented_ctx + + +def is_cert_failure(exc: BaseException) -> bool: + """True when ``exc`` is, wraps (``URLError.reason``) or was caused by a + certificate-verification failure. Anything else — a timeout, a refused + connection, an HTTP status — is not ours to retry.""" + seen: set[int] = set() + stack: list[BaseException] = [exc] + while stack: + cur = stack.pop() + if id(cur) in seen: + continue + seen.add(id(cur)) + if isinstance(cur, ssl.SSLCertVerificationError): + return True + if isinstance(cur, ssl.SSLError) and "CERTIFICATE_VERIFY_FAILED" in str(cur): + return True + for link in (getattr(cur, "reason", None), cur.__cause__, cur.__context__): + if isinstance(link, BaseException): + stack.append(link) + return False + + +class Opener: + """An ``OpenerDirector`` under the trust policy above. + + ``handlers`` go to ``urllib.request.build_opener`` (classes or instances) — + an ``HTTPSHandler`` carrying the chosen context is appended, which replaces + the default one. ``open`` mirrors ``OpenerDirector.open``. The cure is + remembered module-wide, so every Opener in the process benefits from the + first one that found it.""" + + def __init__(self, *handlers) -> None: + self._handlers = handlers + self._by_ctx: dict[int, urllib.request.OpenerDirector] = {} + + def _opener(self, ctx: ssl.SSLContext) -> urllib.request.OpenerDirector: + opener = self._by_ctx.get(id(ctx)) + if opener is None: + opener = urllib.request.build_opener( + *self._handlers, urllib.request.HTTPSHandler(context=ctx)) + self._by_ctx[id(ctx)] = opener + return opener + + @property + def handlers(self) -> list: + """The handlers of the opener in use, as ``OpenerDirector.handlers``.""" + return self._opener(default_context()).handlers + + def open(self, fullurl, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): + global _prefer_augmented + if not _prefer_augmented and default_context().cert_store_stats()["x509"] == 0: + # An empty store verifies nothing: skip the handshake that would fail. + _prefer_augmented = augmented_context() is not None + if _prefer_augmented: + aug = augmented_context() + if aug is not None: + return self._opener(aug).open(fullurl, data, timeout) + try: + return self._opener(default_context()).open(fullurl, data, timeout) + except OSError as exc: # URLError is an OSError; so is a bare SSLError + if not is_cert_failure(exc): + raise + aug = augmented_context() + if aug is None: + raise + resp = self._opener(aug).open(fullurl, data, timeout) + _prefer_augmented = True + return resp + + +_DEFAULT = Opener() + + +def urlopen(url, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): + """``urllib.request.urlopen`` under the plugin's trust policy, no extra handlers.""" + return _DEFAULT.open(url, data, timeout) diff --git a/plugins/memhub/scripts/add_memory_gate.py b/plugins/memhub/scripts/add_memory_gate.py deleted file mode 100644 index ab8f3a81..00000000 --- a/plugins/memhub/scripts/add_memory_gate.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""Deny ``add_memory`` in a session this plugin is already capturing (stdlib only). - -**The failure.** ``add_memory`` exists for MCP clients with no capture hooks: the -caller passes one turn — what the user said, what the assistant replied — and -the server stores it as a conversation. In a Claude Code session running this -plugin that is never needed, because the ``Stop`` hook already uploads every -turn verbatim. Agents call it anyway, to make a finding "findable later" in a -brain, and they fill ``user_message`` with a paraphrase they wrote themselves. -The server stores that as a second conversation, whose user turn the user never -typed, and Studio lists it beside the real session. Blind live sessions on an -unmodified build, asked only to put a finished answer into a new brain, did -this more often than not (the trials are in the PR that added this file). -``save_artifact`` is the tool for what they were after, and the agents that -did not call ``add_memory`` used it. - -**Why here and not on the server.** The server cannot know whether a client -captures its own sessions; only this plugin knows its ``Stop`` hook is live. So -the rule is scoped to exactly that condition and nothing wider: - -* the tool is MemHub's ``add_memory`` — matched on the name's suffix AND on its - ``user_message`` argument, so it covers this plugin's own server and a - claude.ai MemHub connector alike (the blind agents used the connector) without - catching an unrelated server's ``add_memory``; -* per-turn capture is not switched off (``MEMHUB_TURN_FLUSH=0``), and this is - not the plugin's own child process (``MEMHUB_HARNESS_CHILD``), which the - flush scripts never capture; -* the payload names a transcript, which is what the flush reads; -* the plugin holds a credential capture can authenticate with, judged by the - same network-free check the capture-health banner uses — so this and that - banner can never disagree about whether capture is running. - -When any of those is false, capture is not happening and ``add_memory`` is the -only way to save the turn, so the call is allowed. - -**Fails OPEN.** Any unexpected error allows the call: a broken guard must not -take a tool away from a session. - -Run the self-test: python3 tests/add_memory_gate_test.py (from the repo root) -""" -from __future__ import annotations - -import json -import os -import re -import sys -from pathlib import Path -from typing import Mapping - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -# `mcp____add_memory`, whatever the server segment is called. -_ADD_MEMORY = re.compile(r"^mcp__.+__add_memory$") -_CAPTURE_OFF = {"0", "off", "false"} -# `_token_problem` verdicts under which the flush still authenticates today: -# none at all, a key close to expiry, and an OAuth token that works but cannot -# renew. The other verdicts ("never", "key_expired", "unrenewable") mean the -# flush has no working credential, so nothing is being captured. -_CREDENTIAL_WORKS = {None, "key_expiring", "no_refresh"} - -DENY_REASON = ( - "MemHub is already capturing this Claude Code session: the plugin uploads " - "every turn verbatim. add_memory here would store a second conversation " - "whose user message the user never typed, and teammates would see it as a " - "separate session. To keep findings where a brain's readers will find " - "them, save them with save_artifact (pass agent_brain_id). Do not retry " - "add_memory in this session." -) -USER_LINE = ("MemHub: blocked add_memory. This session is already captured, " - "so findings belong in save_artifact.") - - -def is_memhub_add_memory(payload: object) -> bool: - """True for MemHub's add_memory tool, from any server that exposes it.""" - if not isinstance(payload, dict): - return False - name = payload.get("tool_name") - if not isinstance(name, str) or not _ADD_MEMORY.match(name): - return False - args = payload.get("tool_input") - return isinstance(args, dict) and "user_message" in args - - -def capture_is_active(payload: dict, - environ: Mapping[str, str] | None = None) -> bool: - """True when this plugin's per-turn capture is running for this session.""" - env = os.environ if environ is None else environ - if env.get("MEMHUB_TURN_FLUSH", "").strip().lower() in _CAPTURE_OFF: - return False - import transcript_filter # noqa: PLC0415 — stdlib-only, beside this file - if transcript_filter.is_harness_child(env): - # Both flush scripts skip the plugin's own child process, so this - # must judge it the same way. - return False - transcript = payload.get("transcript_path") - if not isinstance(transcript, str) or not transcript.strip(): - return False - import capture_health # noqa: PLC0415 — stdlib-only, beside this file - host = capture_health._env_host() - if not host: - return False - return capture_health._token_problem(host) in _CREDENTIAL_WORKS - - -def decide(payload: object, - environ: Mapping[str, str] | None = None) -> dict | None: - """The hook's stdout document, or None to allow the call silently.""" - if not is_memhub_add_memory(payload) or not capture_is_active(payload, environ): - return None - return { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": DENY_REASON, - }, - "systemMessage": USER_LINE, - } - - -def main() -> int: - try: - raw = sys.stdin.read() - out = decide(json.loads(raw) if raw.strip() else {}) - except Exception: # noqa: BLE001 — fail open, see module docstring - return 0 - if out: - print(json.dumps(out)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/memhub/scripts/agent_transcripts.py b/plugins/memhub/scripts/agent_transcripts.py new file mode 100644 index 00000000..7bc8098b --- /dev/null +++ b/plugins/memhub/scripts/agent_transcripts.py @@ -0,0 +1,202 @@ +"""The agents a Claude Code session spawned, and which of them to capture. + +Claude Code writes every subagent, fork and background agent to its own +transcript beside the session's: + + //.jsonl the session + ///subagents/agent-.jsonl one agent + ///subagents/agent-.meta.json its sidecar + +The sidecar carries ``agentType``, ``description``, ``toolUseId`` (the id of +the ``Agent`` tool_use that spawned it, in the session's transcript or in +another agent's) and ``spawnDepth``. MemHub-Backend captures each agent as its +own conversation linked to the session (spec: MemHub-Backend +docs/specs/agent-sessions-spec.md §4.1). + +**The MemHub harness fork is never captured.** The Stop hook's rule fork +(``harness_stop.py``) is the plugin's own machinery, not the person's work. It +is recognised by the launch prompt the plugin wrote — the spawning tool_use's +``input.prompt`` starts with ``FORK_MARK`` (or, from builds before 2026-09-28, +points at a ``memhub-plugin/harness/…fork-….prompt`` file) — never by +``description``, which the spawning agent chooses. A fork a plugin spawned +itself (sidecar ``pluginSteered``) is skipped the same way. Any agent whose +spawn chain reaches either is skipped too, and so is an agent whose spawning +tool_use cannot be found yet: the next flush looks again. Only the plugin can +do this; the server never sees the prompt. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from harness_stop import FORK_MARK + +_AGENT_PREFIX = "agent-" + +# Plugin builds before 2026-09-28 launched the fork with a pointer to a prompt +# file instead of the prompt itself. Those transcripts are still on disk, and a +# resumed session flushes them. +_LEGACY_FORK_PROMPT = re.compile( + r"^Read \S*memhub-plugin/harness/\S+\.fork-[0-9a-f]+\.prompt and follow it exactly") + + +def is_harness_prompt(prompt: str) -> bool: + return prompt.startswith(FORK_MARK) or bool(_LEGACY_FORK_PROMPT.match(prompt)) + + +def _read_json(path: Path): + try: + return json.loads(path.read_text(encoding="utf-8", errors="replace")) + except (OSError, ValueError): + return None + + +def _spawns(path: Path) -> dict[str, str]: + """``{tool_use id: prompt}`` for every tool call in one transcript. Agents + are spawned by other tools too (a workflow, a skill); only an ``Agent`` + call's prompt can be the harness fork's, so the others map to "". + + Tolerant line by line: the file may be mid-write, and a partial last line + only means a spawn not visible until the next flush.""" + found: dict[str, str] = {} + try: + fh = open(path, encoding="utf-8", errors="replace") + except OSError: + return found + with fh: + for line in fh: + if '"tool_use"' not in line: + continue + try: + record = json.loads(line) + except ValueError: + continue + content = (record.get("message") or {}).get("content") \ + if isinstance(record, dict) else None + for block in content if isinstance(content, list) else (): + if (isinstance(block, dict) and block.get("type") == "tool_use" + and isinstance(block.get("id"), str)): + prompt = (block.get("input") or {}).get("prompt") \ + if block.get("name") in ("Agent", "Task") else None + found.setdefault(block["id"], prompt if isinstance(prompt, str) else "") + return found + + +def agents(session_id: str, transcript_path: str) -> tuple[list[dict], list[str]]: + """The session's agents to capture, and the ids skipped with why. + + Each kept agent is ``{agent_id, path, size, agent_type, description, + tool_use_id, spawn_depth}``. A skip reads ``": "``.""" + sub = Path(transcript_path).parent / session_id / "subagents" + if not sub.is_dir(): + return [], [] + found: dict[str, dict] = {} + for path in sorted(sub.glob(f"{_AGENT_PREFIX}*.jsonl")): + agent_id = path.stem[len(_AGENT_PREFIX):] + meta = _read_json(path.with_suffix(".meta.json")) + found[agent_id] = {"agent_id": agent_id, "path": path, + "meta": meta if isinstance(meta, dict) else {}} + + # A fork's transcript repeats its parent's history, spawning calls + # included, so a call is looked up where it was MADE: in the parent agent + # the sidecar names (`parentAgentId`), else in the session. + calls = {None: _spawns(Path(transcript_path))} + for agent_id, agent in found.items(): + calls[agent_id] = _spawns(agent["path"]) + + def why_not(agent_id: str) -> str | None: + seen: set[str] = set() + current: str | None = agent_id + while current is not None: + if current in seen: + return "spawn chain loops" + seen.add(current) + meta = found[current]["meta"] + if meta.get("pluginSteered"): + # A plugin's own fork ($.agent.spawn), not the person's work. + return "spawned by a plugin" if current == agent_id \ + else "spawned under a plugin's fork" + parent = meta.get("parentAgentId") + owner = parent if isinstance(parent, str) and parent else None + if owner is not None and owner not in found: + return "spawning agent not found yet" + tool_use_id = meta.get("toolUseId") + if tool_use_id is None: + # No tool call spawned it (a fork the person opened), so no + # call can be the harness fork's either. + current = owner + continue + prompt = calls[owner].get(tool_use_id) if isinstance(tool_use_id, str) else None + if prompt is None and owner is None and isinstance(tool_use_id, str): + # A sidecar without `parentAgentId` (older Claude Code builds): + # the first other agent that made the call is the parent. + owner, prompt = next(((aid, c[tool_use_id]) for aid, c in calls.items() + if aid not in (None, current) and tool_use_id in c), + (None, None)) + if prompt is None: + return "spawning call not found yet" + if is_harness_prompt(prompt): + return "MemHub harness fork" if current == agent_id \ + else "spawned under a MemHub harness fork" + current = owner + return None + + kept: list[dict] = [] + skipped: list[str] = [] + for agent_id, agent in found.items(): + reason = why_not(agent_id) + if reason: + skipped.append(f"{agent_id}: {reason}") + continue + meta = agent["meta"] + depth = meta.get("spawnDepth") + try: + size = agent["path"].stat().st_size + except OSError: + skipped.append(f"{agent_id}: transcript unreadable") + continue + kept.append({ + "agent_id": agent_id, + "path": agent["path"], + "size": size, + "agent_type": meta.get("agentType") if isinstance(meta.get("agentType"), str) else None, + "description": meta.get("description") if isinstance(meta.get("description"), str) else None, + "tool_use_id": meta.get("toolUseId"), + "spawn_depth": depth if isinstance(depth, int) and not isinstance(depth, bool) + and 0 <= depth <= 32 else None, + }) + return kept, skipped + + +def import_arguments(session_id: str, agent: dict) -> dict: + """The agent arguments ``import_conversation`` takes (spec §4.1), with the + server's limits applied so a long description cannot fail the import.""" + args = { + "conversation_id": f"{session_id}/{_AGENT_PREFIX}{agent['agent_id']}", + "parent_conversation_id": session_id, + "agent_id": agent["agent_id"], + } + if agent.get("agent_type"): + args["agent_type"] = agent["agent_type"][:100] + if agent.get("description"): + args["agent_description"] = agent["description"][:1000] + if isinstance(agent.get("tool_use_id"), str) and len(agent["tool_use_id"]) <= 200: + args["parent_tool_use_id"] = agent["tool_use_id"] + if agent.get("spawn_depth") is not None: + args["spawn_depth"] = agent["spawn_depth"] + return args + + +def server_accepts_agents(tools: list[dict]) -> bool: + """Whether this server's ``import_conversation`` takes agent arguments. + + An older server ignores arguments it does not know, so sending it an agent + would file the agent as a session of its own — the exact thing this + feature exists to avoid. Checked from the tool's input schema, once per + flush, before any agent is sent.""" + for tool in tools or (): + if isinstance(tool, dict) and tool.get("name") == "import_conversation": + props = (tool.get("inputSchema") or {}).get("properties") or {} + return "agent_id" in props and "parent_conversation_id" in props + return False diff --git a/plugins/memhub/scripts/cursor_capture.py b/plugins/memhub/scripts/cursor_capture.py index d381dea8..f1fb8376 100644 --- a/plugins/memhub/scripts/cursor_capture.py +++ b/plugins/memhub/scripts/cursor_capture.py @@ -15,6 +15,9 @@ import time from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import cursor_rulebook # noqa: E402 — beside this file + _MAX_PAYLOAD_BYTES = 4 * 1024 * 1024 @@ -71,6 +74,23 @@ def spawn_cursor_flush(raw: bytes, event: str) -> None: _log(f"could not launch cursor_flush.py ({exc!r})") +def spawn_rulebook_flush(payload: bytes, event: str) -> None: + """Ship the rulebook's fires at a turn's end, detached like the flusher.""" + try: + planned = cursor_rulebook.flush_call(event, json.loads(payload)) + if planned is None: + return + args, call = planned + script = Path(__file__).resolve().with_name("rulebook_hook.py") + with tempfile.TemporaryFile() as payload_file: + payload_file.write(json.dumps(call).encode("utf-8")) + payload_file.seek(0) + subprocess.Popen([sys.executable, str(script), *args], + stdin=payload_file, **_detached_kwargs()) + except (OSError, ValueError) as exc: + _log(f"could not launch rulebook flush ({exc!r})") + + def _normalize_payload(raw: bytes) -> bytes | None: """Return canonical UTF-8 JSON for the detached child's secure stdin.""" if len(raw) > _MAX_PAYLOAD_BYTES: @@ -143,13 +163,35 @@ def capture_context(payload: bytes) -> str | None: def main() -> int: event = sys.argv[1] if len(sys.argv) > 1 else "unknown" - output = {"permission": "allow"} + # sessionStart and postToolUse take no permission; the rest are asked one. + output = {} if event in ("sessionStart", "postToolUse") else {"permission": "allow"} try: raw = sys.stdin.buffer.read(_MAX_PAYLOAD_BYTES + 1) payload = _normalize_payload(raw) if payload is not None: - spawn_cursor_flush(payload, event) - contexts = [] + if event in cursor_rulebook.NOTE_EVENTS + ("beforeReadFile",): + # Rulebook-only events: capture never read them. + try: + cursor_rulebook.note(event, json.loads(payload)) + except Exception as exc: + _log(f"rulebook note failed ({exc!r})") + else: + spawn_cursor_flush(payload, event) + if event in cursor_rulebook.FLUSH_EVENTS: + spawn_rulebook_flush(payload, event) + cursor_rulebook.note(event, json.loads(payload)) + ruled = None + if event in cursor_rulebook.GATE_EVENTS: + try: + ruled = cursor_rulebook.gate(event, json.loads(payload)) + except Exception as exc: + _log(f"rulebook gate failed ({exc!r})") + if ruled and ruled.get("permission") == "deny": + print(json.dumps(ruled), flush=True) + return 0 + if ruled: + output.update(ruled) + contexts = [ruled["agent_message"]] if ruled and ruled.get("agent_message") else [] if event == "beforeSubmitPrompt": from plugin_compatibility import startup_message try: @@ -169,10 +211,11 @@ def main() -> int: _log(f"context check failed ({exc!r})") context = "\n\n".join(contexts) if context: - output.update(agent_message=context, user_message=context) + output.update(agent_message=context, + user_message=output.get("user_message") or context) except Exception as exc: - # Capture observes; it must never gate the user's prompt or command. + # Capture observes; only a team rule's deny above may gate a call. _log(f"could not launch hook capture ({exc!r})") print(json.dumps(output), flush=True) return 0 diff --git a/plugins/memhub/scripts/cursor_rulebook.py b/plugins/memhub/scripts/cursor_rulebook.py new file mode 100644 index 00000000..01b78b4d --- /dev/null +++ b/plugins/memhub/scripts/cursor_rulebook.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Cursor's rulebook lane: team rules that gate a shell command or a file read. + +Claude Code and Codex run `rulebook_hook.py pre` on PreToolUse. Cursor has no +PreToolUse that sees what a read pulls, so this maps Cursor's own hooks onto +the same Claude-shaped call and back: + +* `beforeShellExecution` -> a `Bash` call. Cursor sends `cwd: ""`, so the + workspace root stands in, and `cat`/`head`/`sed` become the hook's + synthetic reads exactly as they do on Claude Code. +* `beforeReadFile` -> a `Read` call. Cursor's `preToolUse` for Read omits the + offset/limit the agent asked for (observed on cursor-agent 2026.09.15), so + the line count comes from `content` — the slice Cursor is about to hand the + model. A deny there keeps that slice out of the model. + +On a deny, Cursor tells the agent `user_message` and drops `agent_message` +(both events), so the rule's words and its disclosure go in `user_message`. + +A Cursor subagent runs under its own `conversation_id`, with no field naming +its parent, and `subagentStart` does not fire in cursor-agent. So the lane +keeps a little state: `sessionStart` (which fires for the person's +conversation and not for a subagent's) marks a conversation as main, and a +`Task` call in flight marks any other unmarked conversation as its subagent. +That subagent is passed to the hook as `agent_id`, so `given.agent.main` +rules — "delegate big reads to a cheaper model" — spare the very subagent +they asked for. + +A read is gated only when the agent's own Read tool asked for that file a +moment ago (`preToolUse` Read): `beforeReadFile` also fires for files the +person attached, and those are theirs to send. + +Fail-open everywhere: any error answers allow, as capture always has. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +GATE_EVENTS = ("beforeShellExecution", "beforeReadFile") +FLUSH_EVENTS = ("stop", "sessionEnd") +NOTE_EVENTS = ("sessionStart", "preToolUse", "postToolUse") +TASK_TTL_S = 30 * 60 # a Task left in flight (failed, cancelled) stops counting +READ_TTL_S = 60 # preToolUse Read -> beforeReadFile is milliseconds apart +STALE_S = 24 * 3600 +HOOK_TIMEOUT_S = 7 # the Codex bridge's budget for the same call + + +def _state_dir() -> Path: + try: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import rulebook_paths # noqa: PLC0415 — beside this file + base = rulebook_paths.base() + except Exception: + base = (os.environ.get("MEMHUB_RULEBOOK_BASE") + or os.path.expanduser("~/.config/memhub-plugin/rulebook")) + return Path(base) / "cursor" + + +def _key(value: str) -> str: + return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16] + + +def _write(path: Path, doc: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + f".{os.getpid()}.tmp") + tmp.write_text(json.dumps(doc), encoding="utf-8") + os.replace(tmp, path) + + +def _read(path: Path) -> dict | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _sweep(directory: Path, now: float) -> None: + try: + for entry in directory.iterdir(): + if now - entry.stat().st_mtime > STALE_S: + entry.unlink() + except OSError: + pass + + +def note(event: str, data: dict, state: Path | None = None, now: float | None = None) -> None: + """Record what later gates need: which conversation is the person's, + which Task is in flight, which file the agent's Read tool asked for.""" + state = state or _state_dir() + now = time.time() if now is None else now + conv = str(data.get("conversation_id") or "") + if not conv: + return + tool = data.get("tool_name") + if event == "sessionStart": + _write(state / "main" / _key(conv), {"conversation": conv, "at": now}) + _sweep(state / "main", now) + elif event == "preToolUse" and tool == "Task": + call = str(data.get("tool_use_id") or conv) + _write(state / "tasks" / _key(call), {"parent": conv, "at": now}) + _sweep(state / "tasks", now) + elif event == "postToolUse" and tool == "Task": + call = str(data.get("tool_use_id") or conv) + try: + (state / "tasks" / _key(call)).unlink() + except OSError: + pass + elif event == "preToolUse" and tool == "Read": + path = str((data.get("tool_input") or {}).get("file_path") or "") + if path: + _write(state / "reads" / _key(conv + "\0" + path), {"at": now}) + _sweep(state / "reads", now) + elif event in FLUSH_EVENTS: + _end_tasks(conv, state) + + +def _end_tasks(parent: str, state: Path) -> None: + """Forget `parent`'s Tasks. cursor-agent sends no postToolUse for a Task + (nor subagentStop, observed 2026.09.15), so a Task also ends when its + parent acts again — a Task blocks its parent — or its session ends.""" + try: + entries = list((state / "tasks").iterdir()) + except OSError: + return + for entry in entries: + if (_read(entry) or {}).get("parent") == parent: + try: + entry.unlink() + except OSError: + pass + + +def parent_of(conv: str, state: Path, now: float) -> str | None: + """The conversation whose Task spawned `conv`, or None for a main agent.""" + if (state / "main" / _key(conv)).exists(): + return None + newest = None + try: + entries = list((state / "tasks").iterdir()) + except OSError: + return None + for entry in entries: + doc = _read(entry) or {} + parent, at = doc.get("parent"), doc.get("at") or 0 + if parent and parent != conv and now - at < TASK_TTL_S: + if newest is None or at > newest[1]: + newest = (parent, at) + return newest[0] if newest else None + + +def _agent_asked(conv: str, path: str, state: Path, now: float) -> bool: + doc = _read(state / "reads" / _key(conv + "\0" + path)) + return bool(doc) and now - (doc.get("at") or 0) < READ_TTL_S + + +def _line_count(content: str) -> int: + return content.count("\n") + (1 if content and not content.endswith("\n") else 0) + + +def hook_call(event: str, data: dict, state: Path, now: float) -> dict | None: + """The Claude-shaped PreToolUse payload for this Cursor call, or None + when there is nothing to judge.""" + conv = str(data.get("conversation_id") or "") + roots = data.get("workspace_roots") or [] + cwd = data.get("cwd") or (roots[0] if roots else "") + if event == "beforeShellExecution": + command = data.get("command") + if not isinstance(command, str) or not command.strip(): + return None + tool, tool_input = "Bash", {"command": command} + else: + path = data.get("file_path") + content = data.get("content") + if not isinstance(path, str) or not path or not _agent_asked(conv, path, state, now): + return None + tool, tool_input = "Read", {"file_path": path} + if isinstance(content, str): + # What Cursor is about to hand the model; read_facts takes the + # smaller of this and the file's length. + tool_input["limit"] = _line_count(content) + call = {"hook_event_name": "PreToolUse", "tool_name": tool, + "tool_input": tool_input, "cwd": cwd or os.getcwd(), + "session_id": conv, "tool_use_id": data.get("tool_use_id") or "", + "transcript_path": data.get("transcript_path") or ""} + parent = parent_of(conv, state, now) if conv else None + if parent: + # Fires and ordering state belong to the person's session, as a + # Claude Code subagent's do; agent_id marks the call as not-main. + call["session_id"], call["agent_id"] = parent, conv + elif conv: + _end_tasks(conv, state) + return call + + +def to_cursor(event: str, hook_stdout: bytes) -> dict | None: + """rulebook_hook's PreToolUse output, in the shape this Cursor hook takes.""" + try: + doc = json.loads(hook_stdout or b"{}") + except ValueError: + return None + specific = doc.get("hookSpecificOutput") or {} + context = specific.get("additionalContext") or "" + user_line = doc.get("systemMessage") or "" + if specific.get("permissionDecision") == "deny": + reason = specific.get("permissionDecisionReason") or "Blocked by the team rulebook." + text = "\n\n".join(t for t in (reason, context) if t) + # On a deny Cursor shows the agent user_message only (agent_message + # is dropped, observed for both events), so the rule's words and the + # disclosure it asks for go there. + return {"permission": "deny", "user_message": text, "agent_message": text} + if event == "beforeShellExecution" and (context or user_line): + out = {"permission": "allow"} + if context: + out["agent_message"] = context + if user_line: + out["user_message"] = user_line + return out + return None + + +def gate(event: str, data: dict, state: Path | None = None, now: float | None = None) -> dict | None: + """Cursor output for a gated call (deny, or allow with the rule's words), + or None to leave the call alone.""" + state = state or _state_dir() + now = time.time() if now is None else now + call = hook_call(event, data, state, now) + if call is None: + return None + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("rulebook_hook.py")), "pre", + "--host", "cursor"], + input=json.dumps(call).encode(), capture_output=True, + timeout=HOOK_TIMEOUT_S, check=False) + return to_cursor(event, result.stdout) + + +def flush_call(event: str, data: dict, state: Path | None = None, + now: float | None = None) -> tuple[list[str], dict] | None: + """(argv tail, payload) for `rulebook_hook.py flush` at a Cursor turn's or + session's end: what ships this machine's fires and closes their turns, as + the Stop hook does on Claude Code and Codex. None when there is no + conversation to name.""" + conv = str(data.get("conversation_id") or "") + if not conv: + return None + state = state or _state_dir() + now = time.time() if now is None else now + roots = data.get("workspace_roots") or [] + call = {"session_id": conv, "cwd": data.get("cwd") or (roots[0] if roots else os.getcwd()), + "hook_event_name": "SessionEnd" if event == "sessionEnd" else "Stop", + "transcript_path": data.get("transcript_path") or ""} + parent = parent_of(conv, state, now) + if parent: + call["agent_id"] = conv # a subagent's end is not the person's turn + args = ["flush", "final"] if event == "sessionEnd" else ["flush"] + return args + ["--host", "cursor"], call diff --git a/plugins/memhub/scripts/fire_effect.py b/plugins/memhub/scripts/fire_effect.py new file mode 100644 index 00000000..5e65a403 --- /dev/null +++ b/plugins/memhub/scripts/fire_effect.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +"""The window around each fire the agent SAW, for the server's effect judge. + +MemHub's `POST /v1/team/rulebook/fire-effects` (harness-self-improvement-spec +§2) asks, per fire, whether showing the rule helped the work, cost steps, or +changed nothing. It can only answer from what happened AFTER the fire, and +that is in the session transcript on this machine. This module builds one +window per fire and `rulebook_hook.py` ships it through the ledger flush, +exactly as fires and events go: + + Stop (own turn, async) `on_turn_end`: every shown fire of this session + since the last Stop → a window from the turn that + just ended, held in `state/.effects.json` + until the person's next message is known. + UserPromptSubmit `on_prompt`: the person's message is the strongest + evidence the judge has ("thanks" vs "that's not + what I asked"); attach it and move the windows to + `ledger/effects.jsonl`. A wake-up or a /command is + not the person's reaction, so it attaches nothing. + SessionEnd `on_session_end`: ship what is still waiting with + `turn_ended_session: true`. + +The Stop lane runs beside the next turn, so the prompt can land BEFORE the +windows are built: `on_prompt` leaves the prompt and its time in the state +file, and `on_turn_end` attaches it when it is newer than the turn's end. + +One window is: + + person_request the message that started the turn ≤ 2000 chars + before the turn up to the fired-on call ≤ 10 entries + fired_on_call the call the rule fired on ≤ 300 chars + after_the_rule from there to the end of the turn ≤ 45 entries / 9000 chars + next_person_message the person's next message ≤ 1500 chars + turn_ended_session true when there was no next message + +entries are {kind: person|agent|call|result, text}; agent text ≤ 1200, a +call or a result head ≤ 300. The caps are the server's: it REJECTS a longer +row rather than truncating. `fired_on_call` is kept apart from the rest +because the agent wrote it before it saw the rule — a judge shown it inside +`after` credits the rule with the call it fired on. + +The fire's anchor is `tool_use_id`, recorded on the LOCAL fire row by +`log_fires` (never on the wire); a prompt-lane fire anchors on the prompt +itself. A fire whose anchor is not in the turn (a Stop that never ran, a +transcript rotated away) gets no window: nothing is guessed. + +Every text goes through the caller's redactor BEFORE it is cut, as the judge +lane's turn does; with no redactor nothing is built. `MEMHUB_RULEBOOK_EFFECT=0` +turns the whole thing off. Stdlib only; never raises into a hook. +""" +from __future__ import annotations + +import datetime as dt +import json +import os +import re +import sys +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) + +CAPS = {"person": 2000, "agent": 1200, "call": 300, "result": 300} +BEFORE_ENTRIES = 10 +AFTER_ENTRIES = 45 +AFTER_CHARS = 9000 +FIRED_CHARS = 300 +NEXT_CHARS = 1500 +MAX_ENTRIES = 400 # of the turn, the LAST ones — a fire late in a long turn still anchors +MAX_BYTES = 16 * 1024 * 1024 # transcript larger than this: only its tail is read +MAX_PENDING = 64 # windows held per session between a Stop and the next prompt +_SLACK = 4 + +LEDGER = "effects.jsonl" +VERDICTS = "verdicts.jsonl" # ledger/: what the server's judge said, one row per reply verdict +REPAIRS = "repairs.json" # state/: {rule_id: epoch} — the rules a repair was handed for +REPAIR_MIN_P_COST = 0.8 # a cost verdict below this is read, never acted on (the judge is weak on cost) +REPAIR_COOLDOWN_S = 14 * 86400 # one open repair per rule per machine; later costs attach, never spawn +SHOWN_MODES = ("advise", "gate") +WIRE_KEYS = ("fire_id", "person_request", "before", "fired_on_call", "after_the_rule", + "next_person_message", "turn_ended_session") + + +def enabled(environ=None) -> bool: + v = (os.environ if environ is None else environ).get("MEMHUB_RULEBOOK_EFFECT", "") + return v.strip().lower() not in ("0", "false", "no", "off") + + +# ── the transcript ────────────────────────────────────────────────────────── + + +def _hx(): + if _HERE not in sys.path: + sys.path.insert(0, _HERE) + import harness_extract # noqa: PLC0415 — beside this file + return harness_extract + + +def _cut(text, cap, redact): + """Redacted, then cut: a credential straddling the cut would no longer + have the shape a denylist recognises.""" + text = (text or "")[:cap * _SLACK] + try: + text = redact(text) or "" + except Exception: + return "" # a text that could not be redacted is not sent + return text[:cap] + + +def _lines(path): + size = os.path.getsize(path) + with open(path, "rb") as f: + if size > MAX_BYTES: + f.seek(size - MAX_BYTES) + f.readline() + return f.read().splitlines() + + +def _call_text(name, tool_input): + i = tool_input if isinstance(tool_input, dict) else {} + what = str(i.get("command", "")) if name == "Bash" else str(i.get("file_path", "") or "") + if not what: + try: + what = json.dumps(i, default=str) if i else "" + except Exception: + what = "" + return f"{name}: {' '.join(what.split())}" + + +def read_turn(path, redact): + """The last person turn of the transcript at `path`, oldest first: + `[{kind, text, tid}]`, the person's message first (`tid` None), then each + agent text, call and result head with the call's `tool_use_id` as `tid`. + Attachments, `isMeta` records and harness text are skipped — at Stop the + turn ends at the end of the file. `[]` when nothing can be read.""" + if not path or redact is None: + return [] + try: + hx = _hx() + lines = _lines(str(path)) + except Exception: + return [] + groups, kept = [], 0 + try: + for raw in reversed(lines): + if not raw.strip(): + continue + try: + rec = json.loads(raw.decode("utf-8", errors="replace")) + except ValueError: + continue + if not isinstance(rec, dict) or rec.get("isSidechain"): + continue + kind = rec.get("type") + msg = rec.get("message") if isinstance(rec.get("message"), dict) else {} + content = msg.get("content") + if kind == "assistant" and isinstance(content, list): + if kept >= MAX_ENTRIES: + continue + entries = [] + for b in content: + if not isinstance(b, dict): + continue + if b.get("type") == "text" and str(b.get("text") or "").strip(): + t = _cut(hx._SYS_BLOCK.sub("", str(b["text"])).strip(), CAPS["agent"], redact) + if t: + entries.append({"kind": "agent", "text": t, "tid": None}) + elif b.get("type") == "tool_use": + t = _cut(_call_text(str(b.get("name") or ""), b.get("input")), CAPS["call"], redact) + if t: + entries.append({"kind": "call", "text": t, "tid": b.get("id")}) + groups.append(entries) + kept += len(entries) + continue + if kind != "user": + continue + if isinstance(content, list) and any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content): + if kept >= MAX_ENTRIES: + continue + entries = [] + for b in content: + if not (isinstance(b, dict) and b.get("type") == "tool_result"): + continue + body = b.get("content") + body = body if isinstance(body, str) else hx._text_of(body) + body = hx._SYS_BLOCK.sub("", body or "").strip() + t = _cut(" | ".join(body[:CAPS["result"] * _SLACK].splitlines()), CAPS["result"], redact) + if t: + entries.append({"kind": "result", "text": t, "tid": b.get("tool_use_id")}) + groups.append(entries) + kept += len(entries) + continue + text = hx._SYS_BLOCK.sub("", hx._text_of(content)).strip() + if rec.get("isMeta") or not text or hx.is_harness_text(text): + continue # a skill body, a blocked Stop's feedback, a wake-up + person = _cut(text, CAPS["person"], redact) + turn = [{"kind": "person", "text": person, "tid": None}] if person else [] + for entries in reversed(groups): + turn.extend(entries) + return turn[:1] + turn[1:][-MAX_ENTRIES:] + except Exception: + return [] + return [] + + +# ── one fire → one window ─────────────────────────────────────────────────── + + +def _entry(e, room=None): + text = e["text"] if room is None else e["text"][:max(0, room)] + return {"kind": e["kind"], "text": text} + + +def window_for(entries, fire): + """The window for `fire` out of the turn's `entries`, or None when the + fire's anchor is not in this turn.""" + if not entries or entries[0]["kind"] != "person": + return None + person, body = entries[0]["text"], entries[1:] + if fire.get("hook_phase") == "prompt": + before, fired, after = [], f"UserPromptSubmit: {person}", body + else: + tid = fire.get("tool_use_id") + if not tid: + return None + i = next((k for k, e in enumerate(body) if e["kind"] == "call" and e.get("tid") == tid), None) + if i is None: + return None + before, fired, after = body[max(0, i - BEFORE_ENTRIES):i], body[i]["text"], body[i + 1:] + kept, room = [], AFTER_CHARS + for e in after[:AFTER_ENTRIES]: + if room <= 0: + break + kept.append(_entry(e, room)) + room -= len(e["text"]) + return {"person_request": person, "before": [_entry(e) for e in before], + "fired_on_call": fired[:FIRED_CHARS], "after_the_rule": kept, + "next_person_message": "", "turn_ended_session": False} + + +# ── the session's fires since the last Stop ───────────────────────────────── + + +def _ts(s): + try: + return dt.datetime.fromisoformat(str(s).replace("Z", "+00:00")) + except Exception: + return None + + +def session_fires(fires_path, session, since): + """This session's SHOWN fires (advise or gate, not a session-start + exposure) with `fired_at` after `since`, oldest first. Reads the whole + local ledger: it rotates at 1 MB and this runs in an async Stop.""" + out = [] + floor = _ts(since) if since else None + try: + with open(fires_path, "rb") as f: + for line in f: + if not line.endswith(b"\n"): + break + try: + r = json.loads(line.decode("utf-8")) + except Exception: + continue + if not isinstance(r, dict) or r.get("session_id") != session: + continue + if r.get("mode") not in SHOWN_MODES or r.get("hook_phase") == "session" or r.get("probe"): + continue + at = _ts(r.get("fired_at")) + if at is None or (floor is not None and at <= floor): + continue + out.append(r) + except FileNotFoundError: + pass + except Exception: + return [] + out.sort(key=lambda r: r.get("fired_at") or "") + return out + + +# ── the state file and the ledger ─────────────────────────────────────────── + + +def _safe_name(session): + return re.sub(r"[^A-Za-z0-9._-]", "_", str(session or ""))[:80] or "nosession" + + +def pending_path(sdir, session): + return os.path.join(sdir, f"{_safe_name(session)}.effects.json") + + +def _load(p): + try: + with open(p, encoding="utf-8") as f: + st = json.load(f) + return st if isinstance(st, dict) else {} + except Exception: + return {} + + +def _save(p, st): + tmp = f"{p}.{os.getpid()}.tmp" + try: + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(tmp, "w", encoding="utf-8") as f: + json.dump(st, f) + os.replace(tmp, p) + except Exception: + try: + os.unlink(tmp) + except Exception: + pass + + +def _ledger_lock(ldir): + try: + if _HERE not in sys.path: + sys.path.insert(0, _HERE) + import rulebook_ledger # noqa: PLC0415 — beside this file + return rulebook_ledger, rulebook_ledger.append_lock(ldir) + except Exception: + return None, None + + +def ship(ldir, windows, *, next_text, ended): + """Append the windows to `effects.jsonl`, each with the next message and + whether the session ended. Returns how many were written.""" + if not windows: + return 0 + L, lock = _ledger_lock(ldir) + n = 0 + try: + with open(os.path.join(ldir, LEDGER), "a", encoding="utf-8") as f: + for w in windows: + row = {k: w.get(k) for k in WIRE_KEYS} + row["next_person_message"] = next_text or "" + row["turn_ended_session"] = bool(ended) + f.write(json.dumps(row) + "\n") + n += 1 + except Exception: + pass + finally: + if L is not None: + L.release(lock) + return n + + +def wire_row(row): + """The ledger row as the server takes it (nothing else is on the row).""" + return {k: row.get(k) for k in WIRE_KEYS} + + +# ── the three moments ─────────────────────────────────────────────────────── + + +def _collect(ldir, st, session, transcript_path, redact): + """Windows for the session's shown fires past the watermark, from the + last turn of the transcript; moves the watermark.""" + fires = session_fires(os.path.join(ldir, "fires.jsonl"), session, st.get("watermark")) + windows = [] + if fires: + entries = read_turn(transcript_path, redact) + for f in fires: + w = window_for(entries, f) + if w is not None: + windows.append({"fire_id": f.get("fire_id"), **w}) + st["watermark"] = max(f.get("fired_at") or "" for f in fires) or st.get("watermark") + return windows + + +def on_turn_end(ldir, sdir, session, transcript_path, ended_at, redact): + """Build a window for every shown fire of `session` since the last Stop + from the turn that just ended. Windows still waiting from an earlier turn + ship without a next message (the prompt that followed them was not the + person's). Returns the number of windows built.""" + if not enabled() or not session: + return 0 + p = pending_path(sdir, session) + st = _load(p) + ship(ldir, st.pop("pending", None) or [], next_text="", ended=False) + windows = _collect(ldir, st, session, transcript_path, redact) + lp = st.get("last_prompt") if isinstance(st.get("last_prompt"), dict) else None + prompt_at, end_at = _ts(lp.get("at")) if lp else None, _ts(ended_at) + if windows and prompt_at is not None and end_at is not None and prompt_at >= end_at: + ship(ldir, windows, next_text=lp.get("text", ""), ended=False) # it already arrived + elif windows: + st["pending"] = windows[-MAX_PENDING:] + _save(p, st) + return len(windows) + + +def on_prompt(ldir, sdir, session, text, at, redact, *, person=True): + """The person's message arrived: attach it to the waiting windows and + ship them. `person=False` (a wake-up, a /command, another agent's + message) attaches nothing and keeps them waiting.""" + if not enabled() or not session: + return 0 + p = pending_path(sdir, session) + st = _load(p) + if not person: + return 0 + cut = _cut(text, NEXT_CHARS, redact) if redact is not None else "" + st["last_prompt"] = {"text": cut, "at": at} + pend = st.pop("pending", None) or [] + n = ship(ldir, pend, next_text=cut, ended=False) + _save(p, st) + return n + + +def on_session_end(ldir, sdir, session, transcript_path=None, redact=None): + """Ship what is still waiting: there is no next message. The last turn's + fires are collected here too — the Stop lane that would have built their + windows runs async beside this one and may not have run yet (a `-p` + session ends on its first Stop). A fire both lanes window is one row the + server takes twice and judges once.""" + if not enabled() or not session: + return 0 + p = pending_path(sdir, session) + st = _load(p) + pend = st.pop("pending", None) or [] + if transcript_path and redact is not None: + pend += _collect(ldir, st, session, transcript_path, redact) + n = ship(ldir, pend, next_text="", ended=True) + st.pop("last_prompt", None) + _save(p, st) + return n + + +# ── live repair: the judge's verdicts, and the cost fires worth a repair ───── + + +def _num(v): + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + +def record_verdicts(ldir, verdicts): + """Append a `/fire-effects` reply's `verdicts` to `ledger/verdicts.jsonl`, + one row each: `{fire_id, effect, p_helped, p_cost, at}`. The server + answers one verdict per accepted FIRE (a re-sent fire gets its stored + one), so a fire may appear more than once across replies; readers take + the newest. Returns how many were written; never raises.""" + rows = [] + for v in verdicts or []: + if not isinstance(v, dict) or not v.get("fire_id") or v.get("effect") not in EFFECTS: + continue + rows.append({"fire_id": str(v["fire_id"]), "effect": v["effect"], + "p_helped": _num(v.get("effect_helped")), "p_cost": _num(v.get("effect_cost")), + "at": dt.datetime.now(dt.timezone.utc).isoformat()}) + if not rows: + return 0 + L, lock = _ledger_lock(ldir) + try: + with open(os.path.join(ldir, VERDICTS), "a", encoding="utf-8") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + except Exception: + return 0 + finally: + if L is not None: + L.release(lock) + return len(rows) + + +EFFECTS = ("helped", "neutral", "cost") + + +def _verdicts_from(path, offset): + """Complete rows of `verdicts.jsonl` from byte `offset`; (rows, end).""" + rows, end = [], offset + try: + with open(path, "rb") as f: + f.seek(offset) + for line in f: + if not line.endswith(b"\n"): + break + end += len(line) + try: + r = json.loads(line.decode("utf-8")) + except Exception: + continue + if isinstance(r, dict) and r.get("fire_id"): + rows.append(r) + except FileNotFoundError: + pass + except Exception: + return [], offset + return rows, end + + +def pending_repair(ldir, sdir, session, *, now=None, min_p_cost=REPAIR_MIN_P_COST): + """The one fire of `session` worth a repair since this was last asked, or + None: a verdict of `cost` at or above `min_p_cost`, on a shown fire of + this session, for a rule no repair was handed for on this machine within + REPAIR_COOLDOWN_S. Consumes every new verdict it reads (the session's + watermark moves) and, when it answers, marks the rule handed — so the + next Stop never answers the same cost twice, and a second cost on the + same rule attaches to the open repair instead of spawning another. + `{fire_id, rule_id, title, p_cost, tool_use_id, hook_phase, fired_at}`. + Never raises.""" + if not session: + return None + try: + now = time.time() if now is None else now + p = os.path.join(sdir, f"{_safe_name(session)}.repairs.json") + st = _load(p) + rows, end = _verdicts_from(os.path.join(ldir, VERDICTS), int(st.get("verdicts_offset") or 0)) + if not rows: + return None + st["verdicts_offset"] = end + fires = {f.get("fire_id"): f for f in session_fires(os.path.join(ldir, "fires.jsonl"), session, None)} + rp = os.path.join(sdir, REPAIRS) + handed = _load(rp) + latest = {} + for r in rows: # the newest verdict per fire wins + latest[r["fire_id"]] = r + best = None + for fid, r in latest.items(): + f = fires.get(fid) + if f is None or r.get("effect") != "cost" or (r.get("p_cost") or 0) < min_p_cost: + continue + at = handed.get(str(f.get("rule_id"))) + if isinstance(at, (int, float)) and now - at < REPAIR_COOLDOWN_S: + continue + if best is None or (r.get("p_cost") or 0) > (best[1].get("p_cost") or 0): + best = (f, r) + if best is None: + _save(p, st) + return None + f, r = best + handed[str(f.get("rule_id"))] = now + _save(rp, handed) + _save(p, st) + return {"fire_id": f.get("fire_id"), "rule_id": f.get("rule_id"), "title": f.get("title") or "", + "p_cost": r.get("p_cost"), "tool_use_id": f.get("tool_use_id"), + "hook_phase": f.get("hook_phase"), "fired_at": f.get("fired_at")} + except Exception: + return None diff --git a/plugins/memhub/scripts/flush_session.py b/plugins/memhub/scripts/flush_session.py index fffbd5a5..3c8a54ba 100644 --- a/plugins/memhub/scripts/flush_session.py +++ b/plugins/memhub/scripts/flush_session.py @@ -10,6 +10,13 @@ (parallel sessions see fresh decisions), shapes batch episodes into work-unit narratives, and gives the gist's fold-forward an outcome-flavored cadence. +**Then the session's agents.** Once every slice of the session landed, each +subagent, fork and background agent it spawned (``/subagents/``) is +sent as its own conversation linked to the session — except the MemHub +harness fork and anything under it (``agent_transcripts``). Only to a server +whose ``import_conversation`` takes agent arguments; an older one would file +each agent as a session of its own. + **Also the SessionEnd hook.** It must stay a script rather than an ``agent``-type hook: headless ``claude -p`` runs reject agent-type Stop hooks outright (``Agent stop hooks are not yet supported outside REPL``), @@ -50,6 +57,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +import agent_transcripts # noqa: E402 import atomic_write # noqa: E402 import mcp_http # noqa: E402 — stdlib-only now, so no reason to defer it import pr_provenance # noqa: E402 @@ -174,8 +182,7 @@ def _deadline_s() -> float: return value if value > 0 else _DEFAULT_DEADLINE_S -async def _flush(session_id: str, transcript_path: str) -> None: - +def _read_records(transcript_path) -> list: # Tolerant parse, NOT json.loads-or-die: this hook reads the transcript # while Claude Code is still appending to it, so a truncated final line # is the EXPECTED case here, not corruption. One partial line must not @@ -200,10 +207,13 @@ async def _flush(session_id: str, transcript_path: str) -> None: malformed += 1 if malformed: _log(f"skipped {malformed} partial/malformed line(s) (mid-write read)") - if not records: - _log("empty transcript; nothing to flush") - return + return records + +def _prepare(records: list) -> tuple[list, dict | None]: + """The records as they may leave the machine, plus their PR provenance. + Shared by the session and its agents, so an agent cannot come out less + filtered or less redacted than the session that spawned it.""" pr_urls, missing_pr_urls = pr_provenance.scan_tool_results(records) if missing_pr_urls: _log(f"{missing_pr_urls} direct gh pr create result(s) had no " @@ -219,8 +229,7 @@ async def _flush(session_id: str, transcript_path: str) -> None: _log(f"dropped {len(records) - len(kept)} slash-command record(s)") records = kept if not records: - _log("transcript holds only slash-command records; nothing to flush") - return + return [], provenance # A tool result too large for any request the server accepts would # otherwise ride alone in its own slice and fail the whole backstop. @@ -229,7 +238,18 @@ async def _flush(session_id: str, transcript_path: str) -> None: # Same reasoning as the filter above: a session must not come out with # credentials in it depending on which path captured it. Every upload path # redacts, or none of them can be relied on. - records = redact_records(records) + return redact_records(records), provenance + + +async def _flush(session_id: str, transcript_path: str) -> None: + records = _read_records(transcript_path) + if not records: + _log("empty transcript; nothing to flush") + return + records, provenance = _prepare(records) + if not records: + _log("transcript holds only slash-command records; nothing to flush") + return # The name the transcript carries — the user's rename first, then the one # Claude Code generated, then the session's first prompt for a headless run @@ -305,8 +325,6 @@ async def _flush(session_id: str, transcript_path: str) -> None: # failing on the biggest sessions is failing where it matters most. # Slices are disjoint and sent in order against one conversation, # so the server's watermark sees a normal incremental import. - payloads = make_slices(records) - # Bounded by wall clock, not just by slice count. The hook's own # budget is 300s; a many-slice session can exceed it, and being # KILLED there is the bad ending — the process dies mid-upload, @@ -318,17 +336,34 @@ async def _flush(session_id: str, transcript_path: str) -> None: # merely slow stops here — where it can name what it did not send # — instead of being cancelled where it cannot. deadline = time.monotonic() + _deadline_s() * 0.9 + if not await _send_conversation(session, session_id, records, provenance, + {"conversation_id": session_id}, + title, namespace, deadline): + return + # Agents only after the whole session landed: an agent sent first would + # sit unlinked, and a session that failed part-way stops this run anyway. + await _flush_agents(session, url, bearer, session_id, transcript_path, + namespace, deadline) + + +async def _send_conversation(session, session_id: str, records: list, + provenance: dict | None, base: dict, + title, namespace, deadline: float) -> bool: + """Send one conversation's records in ordered slices. True when every + slice landed; False when it stopped (already logged and breadcrumbed).""" + payloads = make_slices(records) for index, payload in enumerate(payloads, 1): if _stop_before_slice(index, time.monotonic(), deadline): remaining = sum(len(p) for p in payloads[index - 1:]) _log(f"deadline reached after {index - 1}/{len(payloads)} " - f"slices; {remaining} record(s) not sent. Re-run " - f"{skill_command('import-session')} --session {session_id} to " - "finish (it resumes from the server's watermark).") - return + f"slices of {base['conversation_id']}; {remaining} record(s) " + f"not sent. Re-run {skill_command('import-session')} --session " + f"{session_id} to finish (it resumes from the server's " + "watermark).") + return False arguments = { + **base, "messages": payload, - "conversation_id": session_id, "source_platform": "claude", } if provenance: @@ -346,7 +381,72 @@ async def _flush(session_id: str, transcript_path: str) -> None: budget=max(_MIN_SLICE_BUDGET_S, deadline - time.monotonic())) if not ok: + return False + return True + + +def _agents_state_path(session_id: str) -> Path: + return _SESSION_STATE_DIR / f"{session_id}.agents" + + +def _sent_sizes(session_id: str) -> dict: + try: + data = json.loads(_agents_state_path(session_id).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +async def _flush_agents(session, url: str, bearer: str, session_id: str, + transcript_path: str, namespace, deadline: float) -> None: + """Send each agent the session spawned as its own conversation, linked to + the session (MemHub-Backend agent-sessions spec §4.1). Never the MemHub + harness fork — see agent_transcripts. + + An agent whose transcript has not grown since it last landed whole is not + re-sent. Its size is recorded only after every slice of it landed, so a + failed or cut-short agent is sent again by the next flush.""" + kept, skipped = agent_transcripts.agents(session_id, transcript_path) + for line in skipped: + _log(f"agent {line}; not sent") + sent = _sent_sizes(session_id) + todo = [a for a in kept if sent.get(a["agent_id"]) != a["size"]] + if not todo: + return + try: + tools = await asyncio.to_thread(mcp_http.list_tools, url, bearer) + except Exception as e: # noqa: BLE001 — the session already landed + _log(f"agents not sent: could not read the server's tools ({e})") + return + if not agent_transcripts.server_accepts_agents(tools): + _log(f"agents not sent: this server predates agent capture " + f"({len(todo)} waiting)") + return + for agent in todo: + if deadline - time.monotonic() < _MIN_SLICE_BUDGET_S: + _log(f"deadline reached; {len(todo) - todo.index(agent)} agent(s) " + "not sent — the next flush sends them") return + records = _read_records(agent["path"]) + records, provenance = _prepare(records) if records else ([], None) + if records and not await _send_conversation( + session, session_id, records, provenance, + agent_transcripts.import_arguments(session_id, agent), + None, namespace, deadline): + return + _log(f"agent {agent['agent_id']} sent, linked to session {session_id[:8]}") + sent[agent["agent_id"]] = agent["size"] + try: + atomic_write.publish(_agents_state_path(session_id), json.dumps(sent)) + except Exception: # noqa: BLE001 — worst case the agent is re-sent + pass + + +def _crumb_key(arguments: dict): + """The session a slice's outcome is recorded against. An agent's is its + session's: `capture_health` reads one file per session, and an agent id + (`/agent-`) is not a file name.""" + return arguments.get("parent_conversation_id") or arguments.get("conversation_id") async def _send(session, arguments, title, namespace, @@ -383,25 +483,25 @@ async def _import(args: dict, timeout: float | None): arguments=args, timeout=timeout) except mcp_http.PluginUpgradeRequired as e: _log(str(e)) - _breadcrumb(args.get("conversation_id"), "upgrade_required", str(e)) + _breadcrumb(_crumb_key(args), "upgrade_required", str(e)) except mcp_http.McpRateLimited as e: wait = f" (retry-after {e.retry_after:.0f}s)" if e.retry_after else "" _log(f"{label}rate limited{wait}; slices already sent are stored") - _breadcrumb(args.get("conversation_id"), "rate_limited", str(e)) + _breadcrumb(_crumb_key(args), "rate_limited", str(e)) except mcp_http.McpNoResponse as e: _log(f"{label}no response frame: {e}") - _breadcrumb(args.get("conversation_id"), "unrecognized_response", str(e)) + _breadcrumb(_crumb_key(args), "unrecognized_response", str(e)) except mcp_http.McpError as e: if e.status == 401: _log(f"{label}credential rejected (401); run {skill_command('login')}") - _breadcrumb(args.get("conversation_id"), "auth", str(e)) + _breadcrumb(_crumb_key(args), "auth", str(e)) elif e.status == 403: _log(f"{label}credential lacks permission (403); check its " "scopes and org access") - _breadcrumb(args.get("conversation_id"), "forbidden", str(e)) + _breadcrumb(_crumb_key(args), "forbidden", str(e)) else: _log(f"{label}transport error: {e}") - _breadcrumb(args.get("conversation_id"), "error", str(e)) + _breadcrumb(_crumb_key(args), "error", str(e)) return None def _texts(result) -> list[str]: @@ -423,7 +523,7 @@ def _texts(result) -> list[str]: # silent made the backstop's MOST LIKELY server-side failure — a slice # the server rejects — the one it reported least, which is the same # asymmetry the transport paths were fixed for one round earlier. - _breadcrumb(arguments.get("conversation_id"), + _breadcrumb(_crumb_key(arguments), "compute_budget_exhausted" if mcp_http.is_compute_budget_rejection(res) else "server_rejected", detail) return False @@ -447,7 +547,7 @@ def _texts(result) -> list[str]: # throttled slice would keep warning for a day even though the backstop # went on to work — the same crying-wolf the per-turn hook clears with # `_mark_success`. - _breadcrumb(arguments.get("conversation_id"), "", ok=True) + _breadcrumb(_crumb_key(arguments), "", ok=True) return True # Not an error per the protocol, but not the shape import_conversation # returns either — log what came back instead of claiming success on an @@ -456,7 +556,7 @@ def _texts(result) -> list[str]: _log(f"{label}flush response unrecognized: {detail!r}") # The last silent exit on this path. Every way `_send` can return False now # leaves a trace — the guarantee is only worth something if it has no holes. - _breadcrumb(arguments.get("conversation_id"), "unrecognized_response", detail) + _breadcrumb(_crumb_key(arguments), "unrecognized_response", detail) return False diff --git a/plugins/memhub/scripts/harness_extract.py b/plugins/memhub/scripts/harness_extract.py index e5767510..21e260d0 100644 --- a/plugins/memhub/scripts/harness_extract.py +++ b/plugins/memhub/scripts/harness_extract.py @@ -414,16 +414,25 @@ def _api(): def server_classify(window: str, repo: str = "", - timeout: float = 0) -> tuple[dict, float]: + timeout: float = 0, session_id: str = "", at: str = "") -> tuple[dict, float]: """ONE bounded POST. Returns ({signal, reason, kind?, derivable?}, seconds). Every failure is a reply with `signal: false` and a CLIENT_REASONS reason, so a caller never has to catch anything. No retry: the caller is a - detached best-effort child.""" + detached best-effort child. + + `session_id` and `at` (the turn's time, ISO) let the server record a + `correction` / `claim_challenge` verdict as a session-wide fire event — + corrections per session, harness-self-improvement-spec §3. Optional: an + older server ignores neither nor refuses them? It refuses unknown fields + (`extra="forbid"`), so they are sent only together and only when set.""" t0 = time.time() body = {"window": window[:WINDOW_MAX_CHARS]} if repo: body["repo"] = repo[:200] + if session_id and at: + body["session_id"] = str(session_id)[:200] + body["at"] = str(at) try: api = _api() except Exception as exc: # noqa: BLE001 — a hook path diff --git a/plugins/memhub/scripts/harness_stop.py b/plugins/memhub/scripts/harness_stop.py index 9acab0ff..8af7e0da 100644 --- a/plugins/memhub/scripts/harness_stop.py +++ b/plugins/memhub/scripts/harness_stop.py @@ -85,6 +85,9 @@ #: What the Stop hook's one line begins with — the only harness text shown, #: and written for the person. `session_rule` names it, so the two move together. HANDOFF_PREFIX = "MemHub: possible team rule spotted in turn" +#: The Stop hook's one line for a repair: the effect judge found a rule COST +#: this session's work (live repair, harness-self-improvement-spec §4). +REPAIR_PREFIX = "MemHub: rule cost this session" def session_rule(session: str) -> str: @@ -95,9 +98,11 @@ def session_rule(session: str) -> str: return ( f"MemHub harness: when a Stop hook line says \"{HANDOFF_PREFIX} N\", run " f"`python3 \"{script}\" handoff --moment {session}#N` with that N and follow its " - f"output. That line has already told the person, so say nothing more about it, and " - f"nothing about a fork that files no rule; if a fork files a rule, tell the person " - f"that one line." + f"output. When one says \"{REPAIR_PREFIX}: … at turn N\", run " + f"`python3 \"{script}\" handoff --moment {session}#N --repair` the same way. " + f"Either line has already told the person, so say nothing more about it, and " + f"nothing about a fork that files no rule; if a fork files or repairs a rule, tell " + f"the person that one line." ) @@ -360,7 +365,8 @@ def judge_previous_turn(session: str, transcript: str, cwd: str) -> dict | None: state = _stamp(session, turn, cwd) window = hx.redact_window(hx.build_window(turn, prev, state, earlier=turns[-5:-3], following=after.get("user") or "")) - reply, secs = hx.server_classify(window, repo=state.get("repo", "")) + reply, secs = hx.server_classify(window, repo=state.get("repo", ""), + session_id=session, at=str(state.get("at") or "")) at = "following" if reply.get("at") == "following" else "new" _log(f"judge {session[:8]} t{n}: signal={bool(reply.get('signal'))} " f"reason={reply.get('reason')} kind={reply.get('kind')} {secs}s " @@ -393,6 +399,8 @@ def fork_launch_prompt(moment: dict, env: str, repo: str = "") -> str: read-only `stamp` command, so no JSON reaches the person's screen.""" turn = moment["turn"] ref = moment["source_ref"] + if moment.get("repair"): + return _repair_launch_prompt(moment, env, repo) kind = moment.get("kind") or "a signal" derivable = (" The classifier thinks it may already be written down, so check that " "first.") if moment.get("derivable") else "" @@ -415,7 +423,8 @@ def fork_launch_prompt(moment: dict, env: str, repo: str = "") -> str: f"{stamp_cmd}. The turn worked in {worked_in}; choose scope_repos from the lesson, " f"not from that. Step 4b (the live forward test) cannot run inside a fork: skip " f"it and file on step 4's verifier, as the Harness-draft path says. File the rule as " - f"`proposed` to the {env} rulebook for everyone in the organisation; activate " + f"`proposed` to the {env} rulebook, for the person alone unless the Harness-draft " + f"path says anyone working in the repo or organisation needs it; activate " f"nothing, ask nothing, touch no " f"settings or credentials. End with exactly one line: \"{FORK_MARK}: filed " f"\", \"{FORK_MARK}: none, <one short reason>\" or \"{FORK_MARK}: failed, " @@ -423,6 +432,43 @@ def fork_launch_prompt(moment: dict, env: str, repo: str = "") -> str: ) +def _repair_launch_prompt(moment: dict, env: str, repo: str = "") -> str: + """The fork prompt for a REPAIR: the effect judge found a rule cost this + session's work at turn N. Same shape as the lesson prompt — who launched + it, on whose install, what it may do — and the one difference: it repairs + an existing rule (a proposed supersede) instead of writing a new one. How + to repair lives in the create-rule skill's harness reference.""" + turn, ref, rp = moment["turn"], moment["source_ref"], moment["repair"] + script = str(Path(__file__).resolve()) + stamp_cmd = (f'python3 "{script}" stamp --transcript "{moment["transcript"]}" ' + f'--turn {turn} --cwd "{moment["cwd"]}"') + title = str(rp.get("title") or rp.get("rule_id") or "")[:120].replace('"', "'") + p = rp.get("p_cost") + pc = f" (p_cost {p:.2f})" if isinstance(p, (int, float)) else "" + return ( + f"{FORK_MARK}, moment {ref}: you were launched by the MemHub plugin's Stop hook, " + f"installed by the person running this session, to REPAIR one team rule. Rule " + f"\"{title}\" (rule_id {rp.get('rule_id')}) fired on turn {turn} of this session and the " + f"effect judge found that showing it cost the work{pc}. Run the memhub create-rule " + f"skill's Harness-repair path on source_ref=\"{ref}\" for that rule_id, with the state " + f"stamp this read-only command prints: {stamp_cmd}. Read turn {turn} — the call the " + f"rule fired on and what the agent did after it — and decide which it is: the rule " + f"fired in a situation it is not for (add a `when_not`), its pattern matched the wrong " + f"shape (a `command_not_rx`), its `do` asked for something this environment refuses " + f"(a new `do`), or nothing was wrong (file nothing). File the repair as a `proposed` " + f"supersede of that rule (`supersedes_rule_id`) in the {env} rulebook; activate " + f"nothing, ask nothing, touch no settings or credentials. End with exactly one line: " + f"\"{FORK_MARK}: repaired <title>\", \"{FORK_MARK}: none, <one short reason>\" or " + f"\"{FORK_MARK}: failed, <why it could not file>\"." + ) + + +def repair_line(repair: dict, turn: int) -> str: + """The Stop hook's one line for a repair, written for the person.""" + title = str(repair.get("title") or repair.get("rule_id") or "")[:80].replace('"', "'") + return f"{REPAIR_PREFIX}: \"{title}\" at turn {turn}; drafting a repair in the background." + + def handoff_line(moment: dict) -> str | None: """The Stop hook's `additionalContext`: ONE line, written for the person and nothing else, because Claude Code prints a Stop hook's context to them in @@ -453,16 +499,64 @@ def fork_reason(moment: dict, env: str, repo: str = "") -> str: pass in silence — a rule that did not get written is not news the person asked for, and the full report stays in the transcript.""" launch = fork_launch_prompt(moment, env, repo) + what = (f"a rule cost the work at turn {moment['turn']}" if moment.get("repair") + else f"turn {moment['turn']} was flagged") return ( - f"{BLOCK_PREFIX}: turn {moment['turn']} was flagged. Launch ONE background fork " + f"{BLOCK_PREFIX}: {what}. Launch ONE background fork " f"of yourself with the Agent tool (subagent_type \"fork\") and give it exactly " f"this prompt, verbatim: <<<{launch}>>> Say nothing to the person about it and do " f"nothing else this turn. When the fork's completion notice arrives: if its last " - f"line says \"filed\", tell the person that one line; if it says \"none\" or " - f"\"failed\", say nothing about it and end that turn with no text." + f"line says \"filed\" or \"repaired\", tell the person that one line; if it says " + f"\"none\" or \"failed\", say nothing about it and end that turn with no text." ) +def _turn_of_fire(turns: list[dict], repair: dict) -> int | None: + """The turn whose tool results include the fire's `tool_use_id`.""" + tid = repair.get("tool_use_id") + if not tid: + return None + for t in turns: + if any(r.get("id") == tid for r in (t.get("results") or [])): + return t.get("n") + return None + + +def pending_repair(session: str, transcript: str) -> dict | None: + """The repair this session owes, with the turn the fire happened on, or + None. Reads the effect judge's verdicts through `fire_effect.py` (the + rulebook hook's state and ledger dirs); never raises.""" + try: + import fire_effect as fe # noqa: PLC0415 — beside this file + import rulebook_hook as rh # noqa: PLC0415 + ldir = rh._ledger_dir() + sdir = os.path.dirname(rh.state_path(session)) + rp = fe.pending_repair(ldir, sdir, session) + if rp is None: + return None + n = _turn_of_fire(hx.turns_from_transcript(transcript), rp) + if n is None: + _log(f"repair {session[:8]}: fire {str(rp.get('fire_id'))[:8]} not in the transcript, dropped") + return None + rp["turn"] = n + fe._save(os.path.join(sdir, f"{fe._safe_name(session)}.repair.json"), rp) + return rp + except Exception: + return None + + +def handed_repair(session: str) -> dict | None: + """The repair the last Stop noted for `session` (`handoff --repair`).""" + try: + import fire_effect as fe # noqa: PLC0415 + import rulebook_hook as rh # noqa: PLC0415 + sdir = os.path.dirname(rh.state_path(session)) + rp = fe._load(os.path.join(sdir, f"{fe._safe_name(session)}.repair.json")) + return rp if rp.get("fire_id") else None + except Exception: + return None + + # ---------------------------------------------------------------- commands def cmd_stop(payload: dict) -> int: session = str(payload.get("session_id") or "") @@ -474,21 +568,31 @@ def cmd_stop(payload: dict) -> int: # A subagent's Stop is not the person's; `stop_hook_active` is the # continuation this hook asked for, i.e. the fork launch itself. return 0 + lines = [] moment = judge_previous_turn(session, transcript, cwd) - if moment is None: - return 0 - line = handoff_line(moment) - if line is None: - _log(f"launch {session[:8]} t{moment['turn']}: skipped, unusable moment ref") + if moment is not None: + line = handoff_line(moment) + if line is None: + _log(f"launch {session[:8]} t{moment['turn']}: skipped, unusable moment ref") + else: + lines.append(line) + _log(f"launch {session[:8]} t{moment['turn']}: fork requested ({moment.get('kind')}) " + f"derivable={int(bool(moment.get('derivable')))}") + # Live repair: a cost verdict on one of this session's fires (the flush + # recorded it from the server's reply) hands the rule to a fork too. + repair = pending_repair(session, transcript) + if repair is not None: + lines.append(repair_line(repair, repair["turn"])) + _log(f"repair {session[:8]} t{repair['turn']}: fork requested for rule " + f"{str(repair.get('rule_id'))[:8]} p_cost={repair.get('p_cost')}") + if not lines: return 0 # `additionalContext`, not `decision: block`: both continue the agent (and # both set `stop_hook_active` on the continuation, probed on 2.1.286), and - # Claude Code prints both to the person in full — so this carries one line, - # and the fork prompt comes from the `handoff` command it names. + # Claude Code prints both to the person in full — so this carries one line + # per moment, and the fork prompt comes from the `handoff` command it names. print(json.dumps({"hookSpecificOutput": { - "hookEventName": "Stop", "additionalContext": line}})) - _log(f"launch {session[:8]} t{moment['turn']}: fork requested ({moment.get('kind')}) " - f"derivable={int(bool(moment.get('derivable')))}") + "hookEventName": "Stop", "additionalContext": "\n".join(lines)}})) return 0 @@ -541,7 +645,7 @@ def _logged_verdict(session: str, turn: int) -> tuple[str, bool]: return ("" if m.group(3) == "None" else m.group(3)), m.group(4) == "1" -def cmd_handoff(ref: str, kind: str, derivable: bool) -> int: +def cmd_handoff(ref: str, kind: str, derivable: bool, repair: bool = False) -> int: """Print what the continued agent does for the moment `ref`: launch one fork with `fork_launch_prompt`. The agent's own call, like `stamp`: read-only, not gated on the flag, and loud when it cannot answer.""" @@ -556,12 +660,18 @@ def cmd_handoff(ref: str, kind: str, derivable: bool) -> int: print(f"no transcript for session {session}", file=sys.stderr) return 2 turns = hx.turns_from_transcript(transcript) + rp = None + if repair: + rp = handed_repair(session) + if rp is None or int(rp.get("turn") or -1) != int(turn_s): + print(f"no repair noted for {ref}", file=sys.stderr) + return 2 for turn in turns: if turn.get("n") == int(turn_s): cwd = turn.get("cwd") or os.getcwd() moment = {"source_ref": ref, "turn": int(turn_s), "kind": kind or None, "derivable": derivable, "state": _stamp(session, turn, cwd), - "transcript": transcript, "cwd": cwd, + "transcript": transcript, "cwd": cwd, "repair": rp, # a moment handed over on `at=following` is the turn the # Stop just ended: the person has said nothing after it yet "next_known": any(t.get("n") == int(turn_s) + 1 for t in turns)} @@ -592,6 +702,8 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--moment", default="", help="handoff: <session_id>#<turn>") p.add_argument("--kind", default="", help="handoff: the classifier's label") p.add_argument("--derivable", action="store_true") + p.add_argument("--repair", action="store_true", + help="handoff: the moment is a cost verdict on turn N, not a lesson") return p @@ -604,7 +716,7 @@ def main(argv: list[str] | None = None) -> int: if args.mode == "handoff": # The continued agent's call, asked for by a Stop that already judged. kind = args.kind if _KIND.match(args.kind or "") else "" - return cmd_handoff(args.moment, kind, args.derivable) + return cmd_handoff(args.moment, kind, args.derivable, repair=args.repair) if not hx.extract_enabled(): try: sys.stdin.read() # drain the hook payload, say nothing diff --git a/plugins/memhub/scripts/hook_entry.py b/plugins/memhub/scripts/hook_entry.py index 6a443f62..39714cbb 100644 --- a/plugins/memhub/scripts/hook_entry.py +++ b/plugins/memhub/scripts/hook_entry.py @@ -30,6 +30,19 @@ the fire flush, capture and harness never are; * an empty ``CLAUDE_PLUGIN_ROOT`` skips the handler (the flush lane says so on stderr); +* the repository allowlist (``repo_allow.py``: ``~/.config/memhub-plugin/ + repos.json``) gates each lane by its ``scope``. With no file every lane + runs, as always. With one, a ``session`` lane (capture, the brief, the + harness, the Rulebook's prompt and session lanes) runs only when the + payload's ``cwd`` is inside a LISTED checkout — a cwd outside any checkout + is dormant too; a ``call`` lane (the Rulebook's pre and post lanes) always + reaches ``rulebook_hook``, which judges each tool call by the checkout the + call acts in (``repo_of_call``), so a session started in a parent folder + of several repos still gets its rules in the listed ones; an ``always`` + lane (the fire flush) runs regardless, since it only ships rows already + recorded. A dormant session is told so once: the SessionStart + ``capture_health`` route answers with one ``additionalContext`` line + (``repo_allow.dormant_note``) instead of running, and nothing else prints; * ``claude_hook_guard`` gates every lane, then an optional prefilter must exit 0, then the script runs with this interpreter and writes to this process's stdout and stderr, so a PreToolUse block's exit code and JSON @@ -154,6 +167,10 @@ class Lane(NamedTuple): run: Tuple[str, ...] # script, then its arguments prefilter: Optional[str] = None # must exit 0 before ``run`` starts isolated: bool = False # run ``run`` in its own interpreter + # The repository allowlist's reach: ``session`` lanes need the session's + # cwd in a listed checkout; ``call`` lanes judge per tool call inside + # rulebook_hook; ``always`` lanes run whatever the list says. + scope: str = "session" class Route(NamedTuple): @@ -165,17 +182,19 @@ class Route(NamedTuple): claim: Optional[str] = None # mod lane that, when claimed, skips this route +SCOPES = ("session", "call", "always") + + def _one(guard: str, *run: str, prefilter: Optional[str] = None, - isolated: bool = False, **route: object) -> Route: - return Route(lanes=(Lane(guard, tuple(run), prefilter, isolated),), **route) + isolated: bool = False, scope: str = "session", **route: object) -> Route: + return Route(lanes=(Lane(guard, tuple(run), prefilter, isolated, scope),), **route) # Every lane not marked ``isolated`` runs in-process (see the docstring); the # isolated ones are exactly the hooks claude-hooks.json marks ``async``. ROUTES = { ("PreToolUse", "rulebook_hook"): _one( - "ignore", "rulebook_hook.py", "pre", claim="pre"), - ("PreToolUse", "add_memory_gate"): _one("ignore", "add_memory_gate.py"), + "ignore", "rulebook_hook.py", "pre", claim="pre", scope="call"), ("PreToolUse", "create_rule_origin"): _one("ignore", "create_rule_origin.py"), # Stage 1 is a cheap test of tool_input.command; flush_prefilter.py is the @@ -192,7 +211,11 @@ def _one(guard: str, *run: str, prefilter: Optional[str] = None, subject=_command_text), ("PostToolUse", "md_capture"): _one("ignore", "md_capture.py"), ("PostToolUse", "rulebook_hook"): _one( - "ignore", "rulebook_hook.py", "post", claim="post"), + "ignore", "rulebook_hook.py", "post", claim="post", scope="call"), + # A failed call's error text, through the same post lane (ENG-1236); the + # mod serves its own error results in-process, so the same claim. + ("PostToolUseFailure", "rulebook_hook"): _one( + "ignore", "rulebook_hook.py", "post", claim="post", scope="call"), # All four are needed: see docs/specs/pr-linking-plugin-spec.md §5.1. ("PostToolUse", "pr_link_trigger"): _one( "ignore", "pr_link_trigger.py", @@ -206,6 +229,9 @@ def _one(guard: str, *run: str, prefilter: Optional[str] = None, ("SessionStart", "harness_stop"): _one( "ignore", "harness_stop.py", "session", harness=True), + # The person typed a command and the host expanded it: noted, so the + # submit that follows is known to be a body, not their words (ENG-1235). + ("UserPromptExpansion", "rulebook_hook"): _one("ignore", "rulebook_hook.py", "expansion"), ("UserPromptSubmit", "brain_brief"): _one("ignore", "brain_brief.py", "prompt"), ("UserPromptSubmit", "rulebook_hook"): _one( "ignore", "rulebook_hook.py", "prompt", claim="prompt"), @@ -220,15 +246,17 @@ def _one(guard: str, *run: str, prefilter: Optional[str] = None, ("Stop", "md_capture_flush"): _one( "ignore", "md_capture_flush.py", prefilter="md_capture_prefilter.py", isolated=True), + # The fire flush ships rows already recorded, so the allowlist never + # stops it: a dormant session has no rows, a listed one owes its fires. ("Stop", "rulebook_hook"): _one( - "ignore", "rulebook_hook.py", "flush", isolated=True), + "ignore", "rulebook_hook.py", "flush", isolated=True, scope="always"), ("Stop", "harness_stop"): _one("ignore", "harness_stop.py", "stop", harness=True), # One handler, two lanes, in order: Claude Code runs an event's handlers # in parallel, and the fire flush can only link rows capture has stored. ("SessionEnd", "session_end"): Route(lanes=( Lane("capture", ("flush_session.py",), isolated=True), - Lane("ignore", ("rulebook_hook.py", "flush", "final"), isolated=True), + Lane("ignore", ("rulebook_hook.py", "flush", "final"), isolated=True, scope="always"), )), } @@ -269,6 +297,42 @@ def _claimed(lane: Optional[str], payload: object, environ) -> bool: return False +def _session_cwd(payload: object) -> str: + cwd = payload.get("cwd") if isinstance(payload, dict) else None + return cwd if isinstance(cwd, str) and cwd else os.getcwd() + + +def session_enabled(payload: object) -> bool: + """repo_allow's verdict for the session's cwd; any failure runs the lane + (a broken allowlist module must not silence every hook).""" + try: + import repo_allow + return bool(repo_allow.session_enabled(_session_cwd(payload))) + except Exception: + return True + + +# The one SessionStart route that speaks for a dormant session. Any of the +# four would do; this one is never claimed by the mod and never turned off by +# a flag, so the note always has a lane to ride. +DORMANT_NOTE_ROUTE = ("SessionStart", "capture_health") + + +def _dormant_note(payload: object) -> Optional[str]: + """The ``additionalContext`` answer for a dormant SessionStart, or None + for a compaction (the session was told at its start).""" + source = payload.get("source") if isinstance(payload, dict) else None + if source == "compact": + return None + try: + import repo_allow + note = repo_allow.dormant_note(_session_cwd(payload), str(SCRIPTS)) + except Exception: + return None + return json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", + "additionalContext": note}}) + + def _flush() -> None: for stream in (sys.stdout, sys.stderr): try: @@ -364,9 +428,23 @@ def dispatch(event: str, name: str, raw: bytes, environ=None) -> int: print("[memhub-flush] CLAUDE_PLUGIN_ROOT unset; cannot locate " "flush scripts, skipping", file=sys.stderr) return 0 + # The repository allowlist, asked once per hook and only when a lane + # needs it: a ``session`` lane in a dormant session is skipped silently, + # except the one SessionStart route that says so to the model. + allowed: Optional[bool] = None status = 0 for lane in route.lanes: status = 0 + if lane.scope == "session": + if allowed is None: + allowed = session_enabled(payload) + if not allowed: + if (event, name) == DORMANT_NOTE_ROUTE: + note = _dormant_note(payload) + if note: + print(note) + _flush() + continue if not _guard(lane.guard, event, raw, payload): continue if lane.prefilter and _run((lane.prefilter,), raw) != 0: diff --git a/plugins/memhub/scripts/login.py b/plugins/memhub/scripts/login.py index 309d1172..0ecf86fe 100644 --- a/plugins/memhub/scripts/login.py +++ b/plugins/memhub/scripts/login.py @@ -38,6 +38,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _tls # noqa: E402 — stdlib-only, sits beside this file import atomic_write # noqa: E402 — stdlib-only, sits beside this file import pak # noqa: E402 — stdlib-only, sits beside this file from plugin_onboarding import HOSTS, LoginCompletion, active_completion, guide_url @@ -187,6 +188,35 @@ def _ensure_key(url: str, env: str) -> str: return "ok" +def _store_explicit_key(url: str, explicit: tuple[str, str]) -> None: + """Keep a verified explicit access key as this machine's stored key. + + Claude Code gives a plugin's headersHelper neither credential variables + nor option values, so an explicit token signs in the hooks but never the + MCP tools; the helper reads only the stored key. A cloud or CI machine starts with none, so + without this its tools stay signed out while every hook works. Only an + access key is kept: an OAuth JWT expires within the day. A stored key that + differs is the person's own sign-in and is left alone. + """ + token, source = explicit + if not token.startswith("mhk_"): + return + stored = pak.load(url) + if stored and stored.get("secret") == token: + print(f"access key : {source} already stored for the MCP tools") + return + if stored: + print(f"access key : kept the stored key; {source} is not stored " + "(the MCP tools use the stored one)") + return + try: + pak.save(url, {"secret": token, "label": f"from {source}"}) + except OSError as exc: + print(f"access key : NOT stored ({exc}); the MCP tools stay signed out") + return + print(f"access key : stored {source} so the MCP tools sign in with it too") + + async def _run(status_only: bool, force: bool) -> int: url = default_url() env = env_for_url(url) @@ -299,6 +329,15 @@ def _restore() -> None: return 1 leaf = _leaf(exc) print(f"status : FAILED ({type(leaf).__name__}: {leaf})") + if _tls.is_cert_failure(exc): + # The transport already retried with the roots this machine + # trusts (see _tls.py); reaching here means those did not + # cover the issuer either, so name the two fixes that do. + print("cause : this python3 cannot verify the server's certificate " + "(its trust store is empty, or lacks your network's root)") + print("fix : run the \"Install Certificates.command\" that came with " + "your Python (python.org builds), or set SSL_CERT_FILE to a CA bundle " + "that trusts it") return 1 # Proven against the server — only now may the stash be discarded. @@ -309,6 +348,8 @@ def _restore() -> None: # Provisioned outside this flow entirely; nothing here owns its # lifecycle, so there is no renewal story to tell and no key to mint. print(f"renewal : n/a ({explicit[1]} is supplied explicitly)") + if not status_only: + _store_explicit_key(url, explicit) return 0 if headers and headers.get("Authorization"): diff --git a/plugins/memhub/scripts/mcp_http.py b/plugins/memhub/scripts/mcp_http.py index 4e777564..d08b2bc5 100644 --- a/plugins/memhub/scripts/mcp_http.py +++ b/plugins/memhub/scripts/mcp_http.py @@ -44,6 +44,7 @@ import urllib.parse import urllib.request +import _tls # noqa: E402 — stdlib-only, sits beside this file from plugin_version import request_headers, upgrade_message # The version this client was written and verified against. Sent so the server @@ -274,10 +275,15 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): def _opener(): - """A module-wide opener that never redirects. Built once, lazily.""" + """A module-wide opener that never redirects. Built once, lazily. + + Built by ``_tls.Opener``, so a Python whose own trust store is empty (a + python.org macOS build before "Install Certificates.command") still + verifies — against the roots the machine already trusts — instead of + failing every call with CERTIFICATE_VERIFY_FAILED.""" global _OPENER if _OPENER is None: - _OPENER = urllib.request.build_opener(_NoRedirects) + _OPENER = _tls.Opener(_NoRedirects) return _OPENER diff --git a/plugins/memhub/scripts/pak.py b/plugins/memhub/scripts/pak.py index bd10760c..65b4ee1e 100644 --- a/plugins/memhub/scripts/pak.py +++ b/plugins/memhub/scripts/pak.py @@ -40,6 +40,7 @@ from pathlib import Path from urllib.parse import urlparse +import _tls import atomic_write import mcp_http @@ -205,7 +206,7 @@ def _call(base: str, bearer: str, method: str, path: str, headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"}) try: - with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + with _tls.urlopen(req, timeout=_TIMEOUT_S) as resp: payload = json.loads(resp.read() or b"{}") except urllib.error.HTTPError as e: body = (e.read() or b"").decode(errors="replace") diff --git a/plugins/memhub/scripts/plugin_updates.py b/plugins/memhub/scripts/plugin_updates.py index 4d5e09d6..d35f5a39 100644 --- a/plugins/memhub/scripts/plugin_updates.py +++ b/plugins/memhub/scripts/plugin_updates.py @@ -3,8 +3,9 @@ import re import time from pathlib import Path -from urllib.request import Request, urlopen +from urllib.request import Request +import _tls import atomic_write from _memhub_auth import _plugin_root from plugin_version import ACTIVE_PLUGIN_VERSION, update_message @@ -21,7 +22,7 @@ def _version(value): def _fetch(path): # No MemHub credentials go to the public release endpoint. - with urlopen(Request(RAW + path, headers={'Accept': 'application/json'}), timeout=0.75) as response: + with _tls.urlopen(Request(RAW + path, headers={'Accept': 'application/json'}), timeout=0.75) as response: raw = response.read(65537) if len(raw) > 65536: raise ValueError('release metadata too large') diff --git a/plugins/memhub/scripts/pr_babysit_trigger.py b/plugins/memhub/scripts/pr_babysit_trigger.py index f1612203..8535ff7b 100644 --- a/plugins/memhub/scripts/pr_babysit_trigger.py +++ b/plugins/memhub/scripts/pr_babysit_trigger.py @@ -106,10 +106,9 @@ def main() -> None: "unless the user asked not to babysit PRs (in this session or in memory), " "start a self-paced loop NOW that babysits it — invoke the loop skill with " f'no interval and the prompt "{skill_command("pr-babysit")} {url}". Each loop pass ' - "polls the PR's review bots (Cursor bugbot, OpenAI Codex) and CI, fixes " - "real findings, pushes, and when the PR is clean saves a review record " - "(each finding, whether it was fixed or rejected, and why) to the repo's " - "MemHub agent brain and ends the loop. Tell the " + "polls the PR's review bots and CI, fixes real findings, pushes, and " + "when the PR is clean proposes a team rule for any fixed finding whose " + "mistake would recur (usually none) and ends the loop. Tell the " "user the babysit loop is running and that saying 'stop the loop' ends it." ) print( diff --git a/plugins/memhub/scripts/pr_link.py b/plugins/memhub/scripts/pr_link.py index ed654c19..196de96f 100644 --- a/plugins/memhub/scripts/pr_link.py +++ b/plugins/memhub/scripts/pr_link.py @@ -3,8 +3,8 @@ the texts the hook injects. Stdlib only, no network at import, every public function pure except -``check()``. The hook entry point is ``pr_link_trigger.py``; the two skills -(`/memhub:link-pr`, `/memhub:find-contributing-sessions`) and +``check()``. The hook entry point is ``pr_link_trigger.py``; the link-pr skill +(`/memhub:link-pr`, every mode including `--find`) and ``capture.py current`` share ``conversation_id_for`` from here so a host cannot silently stop linking because one call site spelled a prefix differently. @@ -367,7 +367,7 @@ def _explicit_method(tokens: list[str], client: str = "curl") -> str | None: # The segment is taken up to the FIRST `__`, not with `[^_]*`: server names # contain underscores all the time. `github_enterprise` is one, and any # plugin-provided server is another — this repo's own tools arrive as -# `mcp__plugin_memhub-staging_memhub__add_memory`. `[^_]*` stopped at the first +# `mcp__plugin_memhub-staging_memhub__save_artifact`. `[^_]*` stopped at the first # underscore and rejected every tool from such a server, so neither the create # link nor the in-play judgment could ever fire for them. _MCP_SPLIT = re.compile(r"(?i)^mcp__(.+?)__(.+)$") @@ -1753,7 +1753,7 @@ def check(pr_url: str, *, timeout: float = CHECK_TIMEOUT_S, now: float | None = "{recovery}" "Then, ONLY IF most of the code in this pull request was written in OTHER sessions (you " "opened it for work done earlier, or you are shipping someone else's branch), add one " - "sentence offering `/memhub:find-contributing-sessions {pr_url}` so those sessions get " + "sentence offering `/memhub:link-pr --find {pr_url}` so those sessions get " "linked too. A pull request has many sessions; yours does not displace them. Do not run it " "without a yes." ) @@ -1769,7 +1769,7 @@ def check(pr_url: str, *, timeout: float = CHECK_TIMEOUT_S, now: float | None = "did, and it is idempotent.\n" "IF IT WAS NOT — you are reviewing, checking out, or commenting on someone else's work, or " "work from an earlier session — do not link. Instead offer, in one sentence, to run " - "`/memhub:find-contributing-sessions {pr_url}` to find the sessions that did write it. Do " + "`/memhub:link-pr --find {pr_url}` to find the sessions that did write it. Do " "not run it without the user saying yes.\n" "{recovery}" "If this session already linked itself to this pull request, say nothing at all." diff --git a/plugins/memhub/scripts/repo_allow.py b/plugins/memhub/scripts/repo_allow.py new file mode 100644 index 00000000..58dbb195 --- /dev/null +++ b/plugins/memhub/scripts/repo_allow.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +"""Which repositories MemHub is active in: the per-machine allowlist. + +Developers also work on client repositories with their coding agent, and +MemHub must not touch those: no team rules, no capture, no companion. So a +machine can carry a list of the repositories MemHub is FOR: + + ~/.config/memhub-plugin/repos.json + {"version": 1, "mode": "allowlist", "repos": ["XTraceAI/MemHub-Backend", ...], + "declined": ["ClientCo/app", ...]} + +* **No file** → MemHub is active everywhere. Every install that exists today + has no file, so an upgrade changes nothing. +* **A file** → the plugin is dormant in any checkout the list leaves out: + the Rulebook fires nothing there, capture stores nothing, and the companion + stays hidden. An EMPTY list is dormant everywhere. A file that cannot be + read as that shape also counts as empty: the person wrote one to narrow + MemHub, and a typo in it must not widen MemHub to every repository. + +An entry is the checkout's identity: ``<org>/<name>`` from the ``origin`` +remote (the two last path segments of its URL, ``.git`` dropped, the way +``room_map.room_name`` builds ``Repo: <org>/<name>``), or the bare ``<name>`` +of a checkout with no ``origin`` (the main worktree's directory, as +``repo_identity`` names it). Matching is exact and case-insensitive. + +``declined`` (ENG-1253) is the repositories the person answered **No** to +when the Claude Code status strip asked whether to enable MemHub there: as +dormant as any unlisted checkout, and never asked about again. ``repos`` +wins over ``declined`` where a name is in both. The key is optional, so a +file an older plugin wrote reads as it did, and an older plugin reading a +file with it ignores the key (it reads ``repos`` alone). + +Two questions, both answered from the SESSION's or the CALL's checkout, +never from the directory name alone (a worktree is named after its branch): + +* ``enabled(path)`` — is MemHub active for the checkout holding ``path``? + ``hook_entry`` asks it of the session cwd before any whole-session lane + (capture, the brief, the session and prompt lanes), ``rulebook_hook`` asks + it of each tool call's own checkout (``repo_of_call``: a session started in + a parent folder of several repos is judged per call), and + ``rulebook_mod_cli.py paths`` answers it to the mod as ``enabled``, so the + TypeScript lanes and the companion honour the same list. +* ``session_enabled(cwd)`` — the same, except that a cwd OUTSIDE any checkout + is dormant when the file exists: there is no repository to be listed. + +Hot path: this runs on every tool call. One ``stat`` of the file when it is +unchanged (the parse is cached by mtime and size), and the checkout's +identity is a few file reads (``.git``, its ``config``), cached per root for +the process. No subprocess, no git. Stdlib only, Python 3.9. +""" +from __future__ import annotations + +import json +import os +import re +from typing import Dict, List, Optional, Set, Tuple + +FILE_NAME = "repos.json" +VERSION = 1 +MODE = "allowlist" + +#: path → ((mtime_ns, size), (listed, declined)): both sets lower-cased. +_LOADED: Dict[str, Tuple[Tuple[int, int], Tuple[Set[str], Set[str]]]] = {} +#: root → (identity as written, lower-cased identity) +_IDENTITY: Dict[str, Tuple[str, str]] = {} + + +# ── where the file is ──────────────────────────────────────────────────────── + +def config_dir() -> str: + """The plugin's per-user state directory (``_memhub_auth`` reads the same + variable for the credentials), ``~/.config/memhub-plugin`` by default.""" + return os.environ.get("MEMHUB_CONFIG_DIR") or os.path.join( + os.path.expanduser("~"), ".config", "memhub-plugin") + + +def allowlist_path() -> str: + """``$MEMHUB_REPOS_FILE``, else ``<config_dir>/repos.json``.""" + return os.environ.get("MEMHUB_REPOS_FILE") or os.path.join(config_dir(), FILE_NAME) + + +# ── the list ───────────────────────────────────────────────────────────────── + +def normalize(entry: object) -> str: + """One entry as it is matched: stripped, ``.git`` dropped, lower-cased, + slashes collapsed. "" for anything that is not a usable name.""" + if not isinstance(entry, str): + return "" + s = entry.strip().strip("/").lower() + if s.endswith(".git"): + s = s[:-4] + s = re.sub(r"/+", "/", s) + return s if s and " " not in s else "" + + +def _names(value: object) -> Set[str]: + return {n for n in (normalize(r) for r in value) if n} if isinstance(value, list) else set() + + +def parse_both(text: str) -> Optional[Tuple[Set[str], Set[str]]]: + """``(listed, declined)`` of a repos.json body, lower-cased, or None when + the body is not the shape this module writes (treated by callers as an + empty list). A missing or malformed ``declined`` is an empty set: the + key is optional, and a typo in it must not unlist anything. A name in + both is listed, not declined.""" + try: + doc = json.loads(text) + except ValueError: + return None + if not isinstance(doc, dict): + return None + repos = doc.get("repos") + if not isinstance(repos, list): + return None + listed = _names(repos) + return listed, _names(doc.get("declined")) - listed + + +def parse(text: str) -> Optional[Set[str]]: + """The listed entries of a repos.json body (``parse_both``'s first half).""" + both = parse_both(text) + return both[0] if both is not None else None + + +def load_both(path: Optional[str] = None) -> Optional[Tuple[Set[str], Set[str]]]: + """``(listed, declined)``: None when there is no file (MemHub active + everywhere), else the two sets of lower-cased entries (an unreadable or + malformed file is two empty sets). Cached by the file's mtime and size.""" + path = path or allowlist_path() + try: + st = os.stat(path) + except OSError: + _LOADED.pop(path, None) + return None + stamp = (st.st_mtime_ns, st.st_size) + hit = _LOADED.get(path) + if hit is not None and hit[0] == stamp: + return hit[1] + try: + with open(path, encoding="utf-8") as f: + parsed = parse_both(f.read()) + except (OSError, UnicodeDecodeError): + parsed = None + value = parsed if parsed is not None else (set(), set()) + _LOADED[path] = (stamp, value) + return value + + +def load(path: Optional[str] = None) -> Optional[Set[str]]: + """The allowlist: None when there is no file (MemHub active everywhere), + else the set of lower-cased listed entries (an unreadable or malformed + file is the empty set).""" + both = load_both(path) + return both[0] if both is not None else None + + +def load_declined(path: Optional[str] = None) -> Set[str]: + """The declined entries, lower-cased; empty with no file.""" + both = load_both(path) + return both[1] if both is not None else set() + + +def restricted(path: Optional[str] = None) -> bool: + """True when a repos.json exists (MemHub is limited to the repos it lists).""" + return load(path) is not None + + +# ── the checkout's identity ────────────────────────────────────────────────── + +def checkout(path: str) -> Tuple[str, str]: + """``(root, gitdir)`` of the checkout holding ``path`` — the nearest + ancestor with a ``.git`` directory or a ``.git`` file (a linked worktree, + whose file names its gitdir) — or ``("", "")`` outside any checkout. + The walk ``rulebook_hook.repo_info`` does, without the name.""" + d = os.path.abspath(path or "") + while d: + g = os.path.join(d, ".git") + if os.path.isdir(g): + return d, g + if os.path.isfile(g): + try: + with open(g, encoding="utf-8") as f: + gitdir = f.read().split(":", 1)[1].strip() + if not os.path.isabs(gitdir): + gitdir = os.path.normpath(os.path.join(d, gitdir)) + except (OSError, IndexError, UnicodeDecodeError): + gitdir = "" + return d, gitdir + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return "", "" + + +def _common_gitdir(gitdir: str) -> str: + """The ``.git`` of the main worktree: ``gitdir`` itself, or where a linked + worktree's ``commondir`` points (``repo_identity._main_worktree_basename``).""" + if not gitdir: + return "" + try: + with open(os.path.join(gitdir, "commondir"), encoding="utf-8") as f: + return os.path.normpath(os.path.join(gitdir, f.read().strip())) + except OSError: + return gitdir + + +_SECTION = re.compile(r'^\s*\[\s*remote\s+"([^"]*)"\s*\]\s*$') +_ANY_SECTION = re.compile(r"^\s*\[") +_URL = re.compile(r"^\s*url\s*=\s*(.+?)\s*$") + + +def origin_url(gitdir: str) -> str: + """The ``origin`` remote's URL from the repository's own ``config`` file + (the main worktree's for a linked worktree), or "" when there is none. + A file read, not ``git remote get-url``: this runs on every tool call.""" + common = _common_gitdir(gitdir) + if not common: + return "" + try: + with open(os.path.join(common, "config"), encoding="utf-8", errors="replace") as f: + lines = f.read().splitlines() + except OSError: + return "" + in_origin = False + for line in lines: + m = _SECTION.match(line) + if m: + in_origin = m.group(1) == "origin" + continue + if _ANY_SECTION.match(line): + in_origin = False + continue + if in_origin: + u = _URL.match(line) + if u: + return u.group(1).strip().strip('"') + return "" + + +def identity_of_url(url: str) -> str: + """``org/name`` from a remote URL, the way ``room_map.room_name`` reduces + one: scp-style ``git@host:org/name.git`` and URL-style + ``https://host/org/name.git`` both keep their LAST two path segments; + a URL with one segment gives the bare name. "" for an empty URL.""" + path = url.strip().rstrip("/") + if path.endswith(".git"): + path = path[:-4] + if "://" in path: + path = path.split("://", 1)[1] + path = path.split("/", 1)[1] if "/" in path else "" + elif ":" in path: + path = path.rsplit(":", 1)[-1] + segments = [s for s in path.split("/") if s] + return "/".join(segments[-2:]) + + +def identity(root: str, gitdir: str = "") -> str: + """The checkout's identity as written in repos.json: ``org/name`` from + ``origin``, else the bare name of the main worktree's directory (a linked + worktree is named after its branch, never after the repo). "" when + ``root`` is not a checkout. Cached per root for the process.""" + if not root: + return "" + hit = _IDENTITY.get(root) + if hit is not None: + return hit[0] + if not gitdir: + _root, gitdir = checkout(root) + if not _root: + return "" + name = identity_of_url(origin_url(gitdir)) + if not name: + common = _common_gitdir(gitdir) + main = os.path.dirname(common) if os.path.basename(common) == ".git" else "" + name = os.path.basename((main or root).rstrip(os.sep)) or os.path.basename(root) + _IDENTITY[root] = (name, normalize(name)) + return name + + +def allows(name: str, path: Optional[str] = None) -> bool: + """Whether ``name`` (an identity) is enabled: no file, or listed.""" + listed = load(path) + if listed is None: + return True + return normalize(name) in listed + + +def is_declined(name: str, path: Optional[str] = None) -> bool: + """Whether the person answered No to enabling MemHub in ``name``: it is + in ``declined`` and not in ``repos``. False with no file.""" + return normalize(name) in load_declined(path) + + +def declined_for(path: str) -> bool: + """``is_declined`` for the checkout holding ``path``; False outside one.""" + root, gitdir = checkout(path) + return bool(root) and is_declined(identity(root, gitdir)) + + +# ── the two answers ────────────────────────────────────────────────────────── + +def enabled(path: str) -> bool: + """Is MemHub active for the checkout holding ``path``? True with no file. + With one, true only for a listed checkout; a ``path`` outside any + checkout is true as well — there is nothing to judge, and the caller + that cares (``session_enabled``) says so itself.""" + listed = load() + if listed is None: + return True + root, gitdir = checkout(path) + if not root: + return True + return normalize(identity(root, gitdir)) in listed + + +def session_enabled(cwd: str) -> bool: + """``enabled(cwd)``, except that with a file a cwd outside any checkout + is dormant: a session started in a folder that is no repository (a parent + of several) is captured for none of them. Its tool calls into a listed + checkout are still judged, call by call, by the Rulebook lanes.""" + listed = load() + if listed is None: + return True + root, gitdir = checkout(cwd) + if not root: + return False + return normalize(identity(root, gitdir)) in listed + + +def status(cwd: str) -> Dict[str, object]: + """One dict for a session: ``restricted`` (a file exists), ``repo`` (the + cwd's identity, "" outside a checkout), ``enabled``, ``declined`` (the + person answered No to this repository), and ``path``.""" + root, gitdir = checkout(cwd) + repo = identity(root, gitdir) if root else "" + return {"restricted": restricted(), "repo": repo, + "enabled": session_enabled(cwd), + "declined": bool(repo) and is_declined(repo), + "path": allowlist_path()} + + +# ── what the hooks say when dormant ────────────────────────────────────────── + +def plugin_name(scripts_dir: Optional[str] = None) -> str: + """``memhub`` or ``memhub-staging``, from the manifest beside ``scripts/`` + (``_memhub_auth.plugin_name`` does the same; this one stays stdlib-light + for the hook entry). ``memhub`` when none is readable.""" + root = os.path.dirname(scripts_dir or os.path.dirname(os.path.abspath(__file__))) + for rel in ("plugin.json", os.path.join(".claude-plugin", "plugin.json")): + try: + with open(os.path.join(root, rel), encoding="utf-8") as f: + name = json.load(f).get("name") + except (OSError, ValueError, AttributeError): + continue + if isinstance(name, str) and name: + return name + return "memhub" + + +def dormant_note(cwd: str, scripts_dir: Optional[str] = None) -> str: + """The one line SessionStart adds to the model's context in a dormant + session: that MemHub is inactive here, and how to enable it.""" + skill = "/%s:repos" % plugin_name(scripts_dir) + info = status(cwd) + if info["repo"] and info["declined"]: + return ("MemHub is inactive in this repository: %s is declined in %s (the person " + "answered No when asked to enable it here). To enable it after all run " + "`%s add .`; `%s all` enables it for every repository." + % (info["repo"], info["path"], skill, skill)) + if info["repo"]: + return ("MemHub is inactive in this repository: %s is not listed in %s. " + "To enable it here run `%s add .`; `%s all` enables it for every repository." + % (info["repo"], info["path"], skill, skill)) + return ("MemHub is inactive here: this folder is not inside a Git checkout, and %s " + "limits MemHub to the repositories it lists. Team rules still apply to tool " + "calls made inside a listed checkout; `%s list` shows them." + % (info["path"], skill)) + + +# ── writing (repos.py and the installer) ───────────────────────────────────── + +def _clean(names: List[str]) -> Dict[str, str]: + """normalized → as written: de-duplicated case-insensitively, ``.git`` dropped.""" + seen: Dict[str, str] = {} + for r in names: + n = normalize(r) + if n and n not in seen: + seen[n] = r.strip().strip("/") + if seen[n].endswith(".git"): + seen[n] = seen[n][:-4] + return seen + + +def write(repos: List[str], path: Optional[str] = None, + declined: Optional[List[str]] = None) -> str: + """Write the allowlist atomically (0600, like the credentials beside it) + and return its path. Entries keep their spelling, are de-duplicated + case-insensitively, and are sorted. ``declined`` None keeps the file's + current declined entries; a listed name is never also declined, so + listing one clears its decline. The key is written only when there is + something in it, so a file with no declines is byte-for-byte what every + version before ENG-1253 wrote.""" + path = path or allowlist_path() + seen = _clean(repos) + if declined is None: + declined = declined_entries(path) or [] + said_no = {k: v for k, v in _clean(declined).items() if k not in seen} + doc: Dict[str, object] = {"version": VERSION, "mode": MODE, + "repos": [seen[k] for k in sorted(seen)]} + if said_no: + doc["declined"] = [said_no[k] for k in sorted(said_no)] + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = "%s.%d.tmp" % (path, os.getpid()) + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(json.dumps(doc, indent=2) + "\n") + os.replace(tmp, path) + finally: + try: + os.unlink(tmp) + except OSError: + pass + _LOADED.pop(path, None) + return path + + +def remove(path: Optional[str] = None) -> bool: + """Delete the allowlist (MemHub active everywhere). True when one was there.""" + path = path or allowlist_path() + _LOADED.pop(path, None) + try: + os.unlink(path) + return True + except FileNotFoundError: + return False + + +def _written(path: Optional[str], key: str) -> Optional[List[str]]: + path = path or allowlist_path() + try: + with open(path, encoding="utf-8") as f: + doc = json.load(f) + except FileNotFoundError: + return None + except (OSError, ValueError, UnicodeDecodeError): + return [] + if not isinstance(doc, dict) or not isinstance(doc.get("repos"), list): + return [] + value = doc.get(key) + if not isinstance(value, list): + return [] + return [r.strip() for r in value if isinstance(r, str) and normalize(r)] + + +def entries(path: Optional[str] = None) -> Optional[List[str]]: + """The listed entries as written (spelling kept), or None when there is + no file. A malformed file reads as [].""" + return _written(path, "repos") + + +def declined_entries(path: Optional[str] = None) -> Optional[List[str]]: + """The declined entries as written, None with no file, [] for none (or a + malformed file). A name also listed is not among them.""" + said_no = _written(path, "declined") + if not said_no: + return said_no + listed = {normalize(r) for r in (entries(path) or [])} + return [r for r in said_no if normalize(r) not in listed] + + +def allow(name: str, path: Optional[str] = None) -> str: + """The strip's **Yes** (and ``repos.py add``): list ``name``, clearing any + decline of it. Creates the file when there is none. Returns the path.""" + return write((entries(path) or []) + [name], path) + + +def decline(name: str, path: Optional[str] = None) -> str: + """The strip's **No**: record ``name`` as declined — dormant, and never + asked about again — and take it off the list if it was on. The file + exists already when the strip asks (it asks only when ``restricted``); + with none this writes one whose list is empty, as the installer does. + Returns the path.""" + key = normalize(name) + kept = [r for r in (entries(path) or []) if normalize(r) != key] + return write(kept, path, (declined_entries(path) or []) + [name]) + + +if __name__ == "__main__": + import sys + print(json.dumps(status(sys.argv[1] if len(sys.argv) > 1 else os.getcwd()))) diff --git a/plugins/memhub/scripts/repos.py b/plugins/memhub/scripts/repos.py new file mode 100644 index 00000000..cc5562f6 --- /dev/null +++ b/plugins/memhub/scripts/repos.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Manage which repositories MemHub is active in (``repo_allow.py``'s list). + + python3 repos.py list what is listed, and this checkout's state + python3 repos.py add [<org/name>|.] list a repository (`.`: the current checkout) + python3 repos.py remove [<org/name>|.] + python3 repos.py all delete the list: MemHub active everywhere + python3 repos.py status [--json] this checkout only, one line + +The list is ``~/.config/memhub-plugin/repos.json`` (``$MEMHUB_REPOS_FILE`` +moves it). No file means MemHub is active in every repository; a file means +it is dormant in every repository the file leaves out — no team rules, no +capture, no companion. ``add`` creates the file when there is none, which +turns every OTHER repository off: the command says so. + +An entry is the checkout's identity, ``<org>/<name>`` from its ``origin`` +remote (``XTraceAI/MemHub-Backend``), or the bare directory name of a +checkout with no remote. ``add .`` and ``remove .`` derive it from the +current directory (or ``--cwd``), so nobody has to spell it. + +The file may also carry ``declined`` (ENG-1253): repositories the person +answered **No** to when the Claude Code status strip asked, at a session +start in an unlisted checkout, whether to enable MemHub there. A declined +repository is as dormant as any unlisted one and is never asked about again. +``list`` and ``status`` show it; ``add`` clears a decline (the repository is +then listed); ``remove`` of a declined repository clears the decline too, so +the strip asks again there. + +Stdlib only, Python 3.9. Exit 0 on success, 2 on a usage error, 1 when the +file cannot be written. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +if HERE not in sys.path: + sys.path.insert(0, HERE) + +import repo_allow # noqa: E402 + + +def _skill() -> str: + return "/%s:repos" % repo_allow.plugin_name(HERE) + + +def _here(cwd: str): + """(root, identity) of the checkout holding ``cwd``, or ("", "").""" + root, gitdir = repo_allow.checkout(cwd) + return (root, repo_allow.identity(root, gitdir)) if root else ("", "") + + +def _resolve(arg: str, cwd: str) -> str: + """The identity an argument names: ``.`` (or nothing) is this checkout.""" + if arg in ("", "."): + root, name = _here(cwd) + if not root: + sys.exit("repos: %s is not inside a Git checkout; name the repository " + "as <org>/<name> instead" % cwd) + return name + name = arg.strip().strip("/") + if name.endswith(".git"): + name = name[:-4] + if not repo_allow.normalize(name) or "://" in name or ":" in name or name.startswith("-"): + sys.exit("repos: %r is not a repository name; use <org>/<name> as the origin " + "remote spells it, or `.` for this checkout" % arg) + return name + + +def cmd_list(args) -> int: + listed = repo_allow.entries() + root, mine = _here(args.cwd) + if listed is None: + print("MemHub is active in every repository (no %s)." % repo_allow.allowlist_path()) + elif not listed: + print("MemHub is active in no repository: %s lists none." % repo_allow.allowlist_path()) + else: + print("MemHub is active only in these repositories (%s):" % repo_allow.allowlist_path()) + for name in listed: + print(" %s" % name) + said_no = repo_allow.declined_entries() or [] + if said_no: + print("Declined (asked at a session start, answered No; MemHub is inactive there " + "and does not ask again):") + for name in said_no: + print(" %s" % name) + if root: + on = repo_allow.enabled(root) + state = "enabled" if on else "declined" if repo_allow.is_declined(mine) else "NOT enabled" + print("This checkout (%s) is %s." % (mine, state)) + if not on: + print("Enable it: %s add ." % _skill()) + else: + print("This directory is not inside a Git checkout.") + return 0 + + +def cmd_add(args) -> int: + name = _resolve(args.repo, args.cwd) + listed = repo_allow.entries() + fresh = listed is None + before = {repo_allow.normalize(r) for r in (listed or [])} + if repo_allow.normalize(name) in before: + print("%s is already listed." % name) + return 0 + was_declined = repo_allow.is_declined(name) + path = repo_allow.allow(name) + print("Added %s to %s.%s" % (name, path, " Its decline is cleared." if was_declined else "")) + if fresh: + print("MemHub is now active ONLY in the listed repositories; every other " + "repository is dormant (no rules, no capture, no companion). " + "`%s all` makes it active everywhere again." % _skill()) + print("Open sessions pick this up at their next tool call; the Claude Code mod " + "and companion at the next session start.") + return 0 + + +def cmd_remove(args) -> int: + name = _resolve(args.repo, args.cwd) + listed = repo_allow.entries() + if listed is None: + print("There is no %s: MemHub is active everywhere, and nothing is listed to remove. " + "`%s add <org/name>` starts a list." % (repo_allow.FILE_NAME, _skill())) + return 0 + key = repo_allow.normalize(name) + kept = [r for r in listed if repo_allow.normalize(r) != key] + if len(kept) == len(listed): + said_no = repo_allow.declined_entries() or [] + still = [r for r in said_no if repo_allow.normalize(r) != key] + if len(still) < len(said_no): + repo_allow.write(listed, declined=still) + print("%s is no longer declined. MemHub stays inactive there, and the Claude Code " + "status strip may ask again whether to enable it; `%s add` enables it now." + % (name, _skill())) + return 0 + print("%s is not listed." % name) + return 0 + repo_allow.write(kept) + print("Removed %s. MemHub is now dormant there." % name) + if not kept: + print("The list is empty: MemHub is dormant in every repository. " + "`%s all` enables it everywhere; `%s add .` enables it here." % (_skill(), _skill())) + return 0 + + +def cmd_all(args) -> int: + if repo_allow.remove(): + print("Removed %s: MemHub is active in every repository." % repo_allow.allowlist_path()) + else: + print("MemHub is already active in every repository (no %s)." % repo_allow.FILE_NAME) + return 0 + + +def cmd_status(args) -> int: + info = repo_allow.status(args.cwd) + if args.json: + print(json.dumps(info)) + return 0 + if not info["restricted"]: + print("active everywhere (no %s)" % repo_allow.FILE_NAME) + elif not info["repo"]: + print("inactive here: not inside a Git checkout (%s limits MemHub to listed repositories)" + % info["path"]) + elif info["enabled"]: + print("active: %s is listed" % info["repo"]) + elif info["declined"]: + print("inactive: %s is declined in %s (answered No when asked; `%s add .` enables it)" + % (info["repo"], info["path"], _skill())) + else: + print("inactive: %s is not listed in %s" % (info["repo"], info["path"])) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="repos.py", description=__doc__.splitlines()[0]) + ap.add_argument("--cwd", default=os.getcwd(), help="the checkout `.` means (default: cwd)") + sub = ap.add_subparsers(dest="cmd") + sub.add_parser("list").set_defaults(fn=cmd_list) + for name, fn in (("add", cmd_add), ("remove", cmd_remove)): + p = sub.add_parser(name) + p.add_argument("repo", nargs="?", default=".") + p.set_defaults(fn=fn) + sub.add_parser("all").set_defaults(fn=cmd_all) + p = sub.add_parser("status") + p.add_argument("--json", action="store_true") + p.set_defaults(fn=cmd_status) + try: + args = ap.parse_args(argv) + except SystemExit as exc: + return 2 if exc.code else 0 + if not args.cmd: + return cmd_list(args) + try: + return args.fn(args) + except OSError as exc: + print("repos: could not write %s: %s" % (repo_allow.allowlist_path(), exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/memhub/scripts/room_map.py b/plugins/memhub/scripts/room_map.py index df09b6dc..4f88eb7d 100644 --- a/plugins/memhub/scripts/room_map.py +++ b/plugins/memhub/scripts/room_map.py @@ -8,7 +8,7 @@ than something each caller rediscovers (and occasionally gets wrong). Its readers are the ARTIFACT writers (`save_artifact.py`, the `.md` -auto-capture, the skills that save a PR review record or a spec) and the +auto-capture, the skills that save a spec) and the SessionStart brief that names the brain (`brain_brief.py`). On a cache miss they call `brain_resolve.resolve_repo_brain`, which does the same exact-name lookup against the server and caches what it finds; a save the diff --git a/plugins/memhub/scripts/rule_decide.py b/plugins/memhub/scripts/rule_decide.py index 95024e78..85f56f2e 100644 --- a/plugins/memhub/scripts/rule_decide.py +++ b/plugins/memhub/scripts/rule_decide.py @@ -52,6 +52,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _tls # noqa: E402 — stdlib-only, sits beside this file + _TIMEOUT_S = 20 _UUID = re.compile(r"^[0-9a-fA-F-]{36}$") _STATUS = {"activate": "active", "reject": "dismissed"} @@ -90,7 +92,7 @@ def decide(rule_id: str, action: str, env: str = "") -> dict: data=json.dumps({"status": _STATUS[action]}).encode(), headers={**headers, "Content-Type": "application/json"}) try: - with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + with _tls.urlopen(req, timeout=_TIMEOUT_S) as resp: payload = json.loads(resp.read() or b"{}") except urllib.error.HTTPError as e: try: @@ -124,7 +126,7 @@ def proposed(session: str) -> dict: req = urllib.request.Request( f"{base}/v1/team/rulebook/rules?status=eq.proposed&author=eq.xtrace" "&order=created_at.desc", headers=headers) - with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + with _tls.urlopen(req, timeout=_TIMEOUT_S) as resp: payload = json.loads(resp.read() or b"{}") except Exception as exc: return {"proposed": [], "error": type(exc).__name__} diff --git a/plugins/memhub/scripts/rulebook_hook.py b/plugins/memhub/scripts/rulebook_hook.py index 93222d3f..9f049f5d 100644 --- a/plugins/memhub/scripts/rulebook_hook.py +++ b/plugins/memhub/scripts/rulebook_hook.py @@ -91,6 +91,7 @@ import time import urllib.parse import uuid + from datetime import datetime, timedelta, timezone def _load_shim(name): @@ -108,6 +109,7 @@ def _load_shim(name): portable_lock = _load_shim("portable_lock") repo_identity = _load_shim("repo_identity") +repo_allow = _load_shim("repo_allow") # the per-machine repository allowlist rulebook_ledger = _load_shim("rulebook_ledger") # ledger rotation, append lock try: # one dir per backend, so staging and prod never share one (rulebook_paths.py) @@ -133,13 +135,37 @@ def _load_shim(name): LEDGER_SCHEMA = 2 # ledger/fires.jsonl row shape (spec §3.2) BOOK_DIR = os.path.join(BASE, "book") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from rulebook_shell import ( # noqa: E402,F401 — split out for size (rulebook_shell.py); tests read expand_path, heredoc_bodies, _HD_OPEN here + BASH_READ_MAX_FILES, + _head_tail_lines, + _sed_lines, + bash_reads, + BASH_EDIT_MAX_BYTES, + BASH_EDIT_MAX_FILES, + _COMMENT_RX, + _HD_OPEN, + _SEPARATOR_RX, + _segment_op, + bash_dirs, + bash_write_targets, + blank_quoted, + blank_syntax, + call_dir, + read_cd, + expand_path, + heredoc_bodies, + shell_only, + strip_leading_assignments, +) + # ── forward-test redirect (spec §4.3.3) ───────────────────────────────────── # # /memhub:create-rule's §4b test arms its candidate in a PRIVATE base instead # of the shared book every session reads. The redirect is keyed on the session # cwd (the test sub-agent's scratch worktree), so only its own calls read the # doctored book. The cwd is the host's, not the model's, so it is the trust -# boundary (see `_acted_on_dir`); a redirect file not owned by the user, or +# boundary for the redirect; a redirect file not owned by the user, or # group-/world-writable, is ignored. REDIRECT_NAME = "pretest-redirect.json" REDIRECT_MAX_AGE_S = 3600 # a forgotten redirect stops steering anything after an hour @@ -174,11 +200,7 @@ def _timeout(default): FLUSH_BATCH = 200 EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit") READ_TOOLS = ("Read",) -BASH_READ_MAX_FILES = 8 # read segments named per Bash call; past that it is a script, not a read READ_COUNT_MAX_BYTES = 64 << 20 # lines are counted this far; a file past it is over any threshold anyone sets -# A Bash call that wrote files is an edit too (see `bash_written_files`). -BASH_EDIT_MAX_FILES = 40 # more than this in one call is a generator, not an edit -BASH_EDIT_MAX_BYTES = 512 * 1024 # per file; bigger is data, not source BASH_EDIT_MAX_STATUS = 4000 # `git status` entries; past that the tree is too noisy to read BASH_EDIT_MARKS_KEPT = 8 # pre-call timestamps kept per session (parallel calls) # Tree rewrites: every touched file has a new mtime but nobody EDITED it, and an @@ -197,27 +219,6 @@ def _timeout(default): LOCAL_PKGS = {"xmem", "evaluation", "tests", "app", "scripts"} -# ── shell-only segment ────────────────────────────────────────────────────── -_HD_OPEN = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_]\w*)\1") # delimiter must be a word, so `x << 2` is a shift - - -def shell_only(cmd): - """Drop heredoc BODY lines; keep every shell line, including commands - after a terminator (`commit -F - <<'MSG' … && git push`). - Known edge: a bit-shift in a multi-line command can arm a bogus skip.""" - out, skip_until = [], None - for line in cmd.split("\n"): - if skip_until is not None: - if line.strip() == skip_until: - skip_until = None - continue - out.append(line) - m = _HD_OPEN.search(line) - if m: - skip_until = m.group(2) - return "\n".join(out) - - # Everything `_SEPARATOR_RX` knows EXCEPT the single pipe, which is left in # so a piped command stays one segment for the receipt test below to refuse. # A bare `&` belongs here: `git fetch & true` is two commands, and reading it @@ -261,26 +262,7 @@ def and_only_segments(shell): return [x.strip() for x in out if x.strip()] -# Quoted spans, blanked to spaces IN PLACE. `shlex` would tokenise properly -# but throws on the half-quoted strings real commands contain, and this only -# needs the contents neutralised, not the tokens. Length-preserving on -# purpose: the separator scans below find operators in the blanked copy and -# slice the ORIGINAL at those offsets, so quoted text survives intact. -# A double-quoted span honours backslash escapes; a single-quoted span has -# none in POSIX shell. -_QUOTED_SINGLE = r"'[^']*'" -_QUOTED_DOUBLE = r'"(?:\\.|[^"\\])*"' -_QUOTED_RX = re.compile(_QUOTED_SINGLE + "|" + _QUOTED_DOUBLE) -# `&&` and `||` first, so the lone-operator alternatives only see what is -# left. A standalone `&` backgrounds the command to its left and the next one -# runs anyway — `gh pr view -R other & git push` is TWO commands. The -# lookarounds keep redirection out: `2>&1`, `cmd >&2`, `cmd &> log`. -_SEPARATOR_RX = re.compile(r"&&|\|\||;|\n|\||(?<![>&])&(?![>&])") _AND_RX = re.compile(r"&&") -_ESCAPE_RX = re.compile(r"\\.", re.S) # a backslash escape, outside quotes -# A `#` starts a comment at the start of a word: after whitespace, an -# operator, or a grouping paren/brace. NOT after `{` — `${#files}` is a length. -_COMMENT_RX = re.compile(r"(?<![^\s;&|()}])#[^\n]*") # A trailing `;` or newline ends the last command; it does not start an empty # one (`pytest;` is a run of pytest). A trailing `&` is NOT a terminator. _TRAILING_TERMINATOR_RX = re.compile(r"[\s;\n]+$") @@ -290,25 +272,6 @@ def trim_terminators(shell): return _TRAILING_TERMINATOR_RX.sub("", shell or "") -def blank_quoted(text): - """`text` with the contents of quoted spans — and every backslash escape - outside them — replaced by spaces, character for character, so every - offset still points at the same place. - - A `\\|` is not a pipe: an escape is data, just as quoting is.""" - return _COMMENT_RX.sub(lambda m: " " * len(m.group(0)), blank_syntax(text)) - - -def blank_syntax(text): - """Quotes and escapes blanked, comments LEFT IN PLACE, length preserved. - - Exposed for `strip_comments`. Escapes become a NON-space placeholder, - because `\\ ` joins two words in bash (`.title\\ #literal` is no comment).""" - blanked = _QUOTED_RX.sub(lambda m: m.group(0)[0] + " " * (len(m.group(0)) - 2) - + m.group(0)[-1], text or "") - return _ESCAPE_RX.sub("\x01\x01", blanked) - - def unquoted(text): """`text` with the CONTENTS of quoted spans removed. @@ -380,51 +343,6 @@ def receipt_segments(shell, whole_chain=False): return [] -# ── a leading assignment is not part of the command ───────────────────────── -# -# `FOO=1 git push` execs `git push`, and a rule has to read it the same way, -# or an ANCHORED rule (`^git\s+push`) is silently bypassed by any prefix — -# including a refused `RULEBOOK_OVERRIDE=` that `strip_override` leaves in. -_ASSIGN_TOKEN_RX = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|\"[^\"]*\"|\S*)\s*") -_ASSIGN_NAME_RX = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") - - -def strip_leading_assignments(shell): - """`shell` with the env assignments that BEGIN a segment removed, byte for - byte identical everywhere else. - - Tokenised per line by `find_override`'s shlex walk, so a quoted - assignment is data and an unparseable line is handed back untouched. A - run is stripped whole (`FOO=1 BAR=2 git push` -> `git push`).""" - out = [] - for line in shell.split("\n"): - try: - lex = shlex.shlex(line, posix=True, punctuation_chars=True) - lex.whitespace_split = True - toks = list(lex) - except ValueError: # unbalanced quoting: not ours to rewrite - out.append(line) - continue - targets, at_start = [], True - for tok in toks: - if at_start and _ASSIGN_NAME_RX.match(tok): - targets.append(tok) # stay at_start: assignments come in runs - continue - at_start = _segment_op(tok) - stripped = line - for val in targets: - for m in _ASSIGN_TOKEN_RX.finditer(stripped): - try: - if shlex.split(m.group(0))[0] != val: - continue - except (ValueError, IndexError): - continue - stripped = stripped[:m.start()] + stripped[m.end():] - break - out.append(stripped) - return "\n".join(out) - - def strip_comments(text): """`text` with `#` comments blanked and quotes left INTACT, length preserved. @@ -464,10 +382,11 @@ def command_fires(rx, text, not_rx=None, flags=re.I | re.M): # ── files a Bash call wrote ───────────────────────────────────────────────── # # A model writes files through Bash too (`cat > f <<EOF`, `sed -i`, a Python -# heredoc). The command line cannot recover the path, so this reads the DISK: -# the pre lane stamps the call, the post lane asks git what changed since and -# feeds each file through the same matcher a Write goes through. It lands -# AFTER the write, so it advises only. +# heredoc). The writes the command line shows are judged BEFORE it runs +# (`bash_write_targets`, below) and can gate. This is the backstop for the +# rest, read from the DISK: the pre lane stamps the call, the post lane asks +# git what changed since and feeds each file through the same matcher a Write +# goes through. It lands AFTER the write, so it advises only. def _worktrees(root): """Every worktree of `root`'s repository, `root` first. Empty on any failure.""" @@ -527,19 +446,26 @@ def apply_patch_files(inp, cwd): for path, is_new, added in files[:BASH_EDIT_MAX_FILES]] -def bash_written_files(root, cmd, since): - """(path, is_new) for each regular file a Bash call left modified or new. +def bash_written_files(root, cmd, since, also=()): + """(path, is_new, worktree) for each regular file a Bash call left + modified or new. - Scanned: the session's worktree, plus any sibling worktree the command - names (not all of them — one `git status` each per call is too costly). - `git status` decides candidates (so .gitignore excludes), the mtime what - THIS call touched. Past BASH_EDIT_MAX_FILES it returns [] — a generator - or tree rewrite, not an edit. + Scanned: the call's worktree; every worktree in `also` — the checkouts + the command was resolved to run in (`bash_dirs`: `cd ~/wt`, `cd ../wt`, + `git -C "$WT"`, however it is spelled) and the session's own; and any + sibling worktree whose path the command spells out literally (not all of + them — one `git status` each per call is too costly). `git status` + decides candidates (so .gitignore excludes), the mtime what THIS call + touched. Past BASH_EDIT_MAX_FILES it returns [] — a generator or tree + rewrite, not an edit. """ if not root or since is None or _TREE_REWRITE_RX.search(shell_only(cmd or "")): return [] roots = [w for w in _worktrees(root) if w == root or any(n in (cmd or "") for n in _names_of(w))] + for w in also: + if w and not any(_same_dir(w, r) for r in roots): + roots.append(w) out = [] for wt in roots: try: @@ -573,141 +499,12 @@ def bash_written_files(root, cmd, since): # a coarse filesystem can under-count, the safe direction. if not stat.S_ISREG(st.st_mode) or st.st_mtime < since: continue - out.append((path, is_new)) + out.append((path, is_new, wt)) if len(out) > BASH_EDIT_MAX_FILES: return [] return out -# ── files a Bash call READS into context ──────────────────────────────────── -# -# A read rule (`event: read`) is about what enters the model's context: the -# Read tool, and `cat`/`head`/`tail`/`less`/`more`/`sed` on a path (most of -# the largest tool results are whole-file shell dumps). -# -# Read from the COMMAND, not the disk, because this lane must gate. A pipe or -# redirect is not a read into context, and every doubt resolves to "no read -# here" — under-count, never false-fire. `cd` is tracked segment by segment -# (`cd <repo> && cat spec.md`). - -_READ_CMDS = ("cat", "head", "tail", "less", "more", "sed") -# stdout going somewhere other than the context: `> f`, `>> f`, `&> f`, and a -# heredoc opener (its stdin is data). `2>/dev/null` and `2>&1` are not that. -_REDIRECT_RX = re.compile(r"(?<![0-9&<])>(?!&)|&>|<<") -_SED_RANGE_RX = re.compile(r"^(\d+)(?:,(\+)?(\d+|\$))?p$") -_LINES_FLAG_RX = re.compile(r"^(?:-n|--lines=)(\d+)$|^-(\d+)$") - - -def _head_tail_lines(args): - """(lines printed, indices of the flag VALUES) for `head`/`tail`: 10 by - default, `-N`, `-n N`, `-nN`, `--lines=N`. (None, None) for a form this - does not name (`-c` bytes, `-n +N`) — not a read this can measure.""" - n, i, used = 10, 0, set() - while i < len(args): - a = args[i] - if a in ("-c", "--bytes") or a.startswith("--bytes=") or a.startswith("-c"): - return None, None - if a in ("-n", "--lines"): - if i + 1 >= len(args) or not args[i + 1].isdigit(): - return None, None - n, used, i = int(args[i + 1]), used | {i + 1}, i + 2 - continue - m = _LINES_FLAG_RX.match(a) - if m: - n = int(m.group(1) or m.group(2)) - i += 1 - return n, used - - -def _sed_lines(args): - """(lines printed, indices that are not files) for a `sed` that prints - to stdout. `-n 'A,Bp'` is a range, `-n 'Ap'` one line, `-n '1,$p'` the - whole file; `sed s/a/b/ f` with no -n prints every line. `-i` writes in - place and prints nothing, and a script this cannot read is not a read — - both answer (None, None).""" - if any(a == "-i" or a.startswith("-i") or a == "--in-place" for a in args): - return None, None - quiet, script, used, i = False, None, set(), 0 - while i < len(args): - a = args[i] - if a in ("-n", "--quiet", "--silent"): - quiet = True - elif a in ("-e", "--expression", "-f", "--file"): - if i + 1 < len(args): - used.add(i + 1) - if a in ("-e", "--expression"): - script = args[i + 1] - i += 2 - continue - elif a.startswith("-"): - pass - elif script is None: - script, used = a, used | {i} - i += 1 - if script is None: - return None, None - if not quiet: - return None, used # prints the whole file, transformed - m = _SED_RANGE_RX.match(script.replace(" ", "")) - if not m: - return None, None # `/x/,/y/p` and friends: not a read we can measure - a, plus, b = int(m.group(1)), m.group(2), m.group(3) - if b is None: - return 1, used - if b == "$": - return None, used - return (int(b) + 1 if plus else max(int(b) - a + 1, 0)), used - - -def bash_reads(cwd, cmd): - """(path, pulled) for each file a Bash call would print into the context; - `pulled` is the line count the form asks for, None meaning every line. - Relative paths resolve where the command runs, `cd` included. Anything - this cannot name is not a read — the list is empty on every doubt.""" - out = [] - here = os.path.expanduser(cwd or "") or os.getcwd() - for seg in re.split(r"&&|\|\||;|\n", shell_only(cmd or "")): - seg = strip_leading_assignments(seg.strip()).strip() - if not seg: - continue - try: - toks = shlex.split(seg) - except ValueError: - continue - if not toks: - continue - if toks[0] == "cd": - target = os.path.expanduser(toks[1]) if len(toks) > 1 else os.path.expanduser("~") - here = target if os.path.isabs(target) else os.path.normpath(os.path.join(here, target)) - continue - if "|" in seg or _REDIRECT_RX.search(seg): - continue - name = os.path.basename(toks[0]) - if name not in _READ_CMDS: - continue - args, pulled, used = toks[1:], None, set() - if name in ("head", "tail"): - pulled, used = _head_tail_lines(args) - if used is None: - continue - elif name == "sed": - pulled, used = _sed_lines(args) - if used is None: - continue - # a token with `<` or `>` in it is a redirection operand (`2>/dev/null`), - # never a file to read - files = [a for i, a in enumerate(args) - if i not in used and a != "-" and not a.startswith("-") and "<" not in a and ">" not in a] - for f in files: - path = os.path.expanduser(f) - if not os.path.isabs(path): - path = os.path.normpath(os.path.join(here, path)) - out.append((path, pulled)) - if len(out) >= BASH_READ_MAX_FILES: - return out - return out - - def read_facts(path, pulled=None, offset=None, limit=None, total=None): """{"lines": n, "bytes": b} — what this read would pull into the context: the file's length, narrowed by `offset`/`limit` or a shell form's line @@ -1014,29 +811,22 @@ def feed(self, rule, *, hook_phase, tool, cmd="", file_path="", ok=None, armed=N def bash_ok(resp, *, strict=False): - """Did the Bash call succeed? Uses exit_code when the harness supplies it. - Without one, the text proxy (same as the transcript replayer) is a guess a - command's own output could forge — so `strict=True` (used for GATE-mode - receipts) returns False unless an explicit exit_code says 0.""" - if resp is None: # no result at all is never a receipt - return False - if isinstance(resp, dict): - if isinstance(resp.get("exit_code"), int): - return resp["exit_code"] == 0 - if resp.get("is_error") or resp.get("isError"): - return False - if strict: + """Did the Bash call succeed? (rulebook_moments.bash_ok: exit_code wins, + is_error is red, `strict` wants an explicit 0, else a text proxy.) A lone + copy of this file answers False: no receipt without the sibling.""" + try: + return _rm().bash_ok(resp, strict=strict) + except ImportError: return False - txt = result_text(resp) - # text proxy, anchored to pytest/traceback vocabulary — a green run whose - # output merely mentions "error:" must not be mistaken for red - return not re.search( - r"(^|\n)(FAILED|ERROR)\b|\b\d+ (failed|errors?)\b|\nTraceback \(most recent call last\)" - r"|(^|\n)npm ERR!|(^|\n)error(\[E\d+\])?:", txt) # ── given: predicates a matched rule must also satisfy ────────────────────── PROBE_TIMEOUT_S = 1.0 # per git call; a probe past it answers None, never blocks the call +PROBE_CACHE_TTL_S = 600 # a cached base answer is trusted this long at most (ENG-1235) + + +def _probe_cache_path(root): + return os.path.join(_base(), "state", f"probes-{worktree_key(root)}.json") _TURNS_MAX_BYTES = 16 * 1024 * 1024 # transcript larger than this: only its tail is read _TURNS_KEEP = 200 # most recent user turns kept _TURN_CHARS = 2000 # per turn @@ -1205,46 +995,12 @@ def given_norm(g): def user_turns_of(tp): - """What the person typed this session, oldest first. A transcript `user` - record is also how tool results and injected context arrive, so this keeps - only real prompts: no `toolUseResult`, no `tool_result` block, no `isMeta` - row, no compaction summary. A substring pre-filter keeps it to one - json.loads per candidate line. None when there is no transcript — and - None never satisfies a `user` predicate.""" - if not tp: - return None + """What the person typed this session, oldest first: real prompts only + (rulebook_moments.user_turns_of). None with no transcript or no sibling, + and None never satisfies a `user` predicate.""" try: - size = os.path.getsize(tp) - turns = [] - with open(tp, "rb") as f: - if size > _TURNS_MAX_BYTES: - f.seek(size - _TURNS_MAX_BYTES) - f.readline() # the cut line - for raw in f: - if b'"user"' not in raw or b'"type"' not in raw: - continue - try: - rec = json.loads(raw) - except Exception: - continue - if rec.get("type") != "user" or rec.get("isMeta") \ - or rec.get("isCompactSummary") or "toolUseResult" in rec: - continue - c = (rec.get("message") or {}).get("content") - if isinstance(c, str): - text = c - elif isinstance(c, list): - if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c): - continue - text = "\n".join((b.get("text") or "") for b in c - if isinstance(b, dict) and b.get("type") == "text") - else: - continue - text = text.strip() - if text: - turns.append(text[:_TURN_CHARS]) - return turns[-_TURNS_KEEP:] - except Exception: + return _rm().user_turns_of(tp, _TURNS_MAX_BYTES, _TURN_CHARS, _TURNS_KEEP) if tp else None + except ImportError: return None @@ -1318,6 +1074,15 @@ def command_root(cwd, command): return repo_info(path)[1] +# ── where a Bash call works, and what it writes ───────────────────────────── +# +# A session runs in one directory and acts in many: `cd ~/xtrace/xmem && git +# push`, `git -C ../wt status`, `cd "$WT" && python3 - <<'EOF' … open(…, 'w')`. +# Read from the COMMAND (pure: no file system), so the pre lane can act before +# the command runs. Every doubt resolves to "" (unknown), and the caller falls +# back to the session's own directory — never a guess at a sibling. + + class Probes: """The facts a `given` block asks about, answered lazily and at most once per hook call, read-only, bounded by PROBE_TIMEOUT_S, never leaving the @@ -1439,7 +1204,34 @@ def compute(): if best is None or n < best: nearest, best = mb, n return nearest - return self._get("base", compute) + return self._get("base", lambda: self._cached("base", compute)) + + def _cache_key(self): + """HEAD, the refs' fingerprint, the base the command names and the + env override: everything `base()` reads. None → not cacheable.""" + head = (self._git("rev-parse", "HEAD") or "").strip() + try: + fp = _rm().refs_fingerprint(repo_info(self.root)[2]) + except Exception: + fp = None + if not head or fp is None: + return None + named = sorted({next((g for g in m.groups() if g), "") + for m in _BASE_ARG.finditer(self._cmd)} - {""}) + env = os.environ.get("MEMHUB_RULEBOOK_BASE_BRANCH", "").strip() + return f"{head}|{fp}|{','.join(named)}|{env}" + + def _cached(self, name, compute): + """`compute()` memoised ACROSS hook calls in a per-checkout file keyed + on everything the answer reads (rulebook_moments.cached): base + discovery was a dozen git subprocesses on every call that matched a + `given` rule. A fixture (the verifier, tests) and a rootless probe + bypass it.""" + if self._fix or not self.root: + return compute() + return _rm().cached(_probe_cache_path(self.root), name, self._cache_key(), compute, + now=_now, age_s=_age_s, atomic_json=_atomic_json, + ttl=PROBE_CACHE_TTL_S) def _merge_base(self, cand): """`merge-base(cand, HEAD)` when `cand` resolves to a commit, else None.""" @@ -1616,8 +1408,8 @@ def _redirect_base(cwd): if not base or not prefix or not os.path.isdir(base): return "" here = os.path.normpath(os.path.abspath(cwd)) - # Both spellings must hold, as in `_acted_on_dir`: a symlink must not - # be able to pull a session into the test's book or out of it. + # Both spellings must hold: a symlink must not be able to pull a + # session into the test's book or out of it. if not (_under(here, os.path.normpath(os.path.abspath(prefix))) and _under(os.path.realpath(here), os.path.realpath(prefix))): return "" @@ -2141,22 +1933,51 @@ def maybe_refresh(repo, fetched_at): return if _age_s(fetched_at) < REFRESH_AFTER_S: return + if _refresh_due(repo): + try: + spawn_fetch(repo) + except Exception: + pass + + +def _refresh_due(repo): + """True once per REFRESH_RETRY_S per repo. The `.refresh` stamp records the + ATTEMPT, so it is written BEFORE any fetch: a fork that fails under + resource pressure must not be retried on every tool call, and a minute + before the next try costs at most one override on a rule the server has + since retired. The other order was tried and reverted.""" stamp = book_path(repo) + ".refresh" try: with open(stamp, encoding="utf-8") as f: if _age_s(json.load(f).get("at")) < REFRESH_RETRY_S: - return + return False except Exception: pass try: - # The stamp records the ATTEMPT, so it goes first: a fork that fails - # under resource pressure must not be retried on every tool call, and - # a minute before the next try costs at most one override on a rule - # the server has since retired. The other order was tried and reverted. _atomic_json(stamp, {"at": _now()}) - spawn_fetch(repo) + except Exception: + return False + return True + + +def first_fetch(repo, rules, fetched_at, sources): + """(rules, fetched_at, sources): a repo with NO cached book gets the + session lane's one bounded fetch before this call is judged. A session + started outside any checkout — a folder holding an app's bare `.git` and + its worktrees (ENG-1249) — had no SessionStart fetch, and the detached + refresh lands AFTER the first call that names the repo: the edit the rule + was for fired nothing, 3 of 7 PR-tier runs. Once a minute per repo + (`_refresh_due`), so a dead server costs one timeout a minute, not one a + call. A cached book, however stale, is `maybe_refresh`'s.""" + if (fetched_at is not None or upgrade_status(repo) + or os.environ.get("MEMHUB_RULEBOOK_FETCH", "1") == "0" or not _refresh_due(repo)): + return rules, fetched_at, sources + try: + fetch_book(repo, timeout=SESSION_FETCH_TIMEOUT_S) + rules, _, fetched_at, sources = load_rules(repo) except Exception: pass + return rules, fetched_at, sources def path_in_scope(rule, path, root=""): @@ -2808,86 +2629,45 @@ def spawn_fetch(repo): # GATE row it is the excuse given on the blocked call, the row's own record. # `judge_score` / `judge_verdict` (rule-judge-spec §4) are on a row only when # the judge answered for that fire; see `wire_row`. +def _rm(): + """`rulebook_moments`, imported beside this file on first use: what the + hook's size limit moved out (ENG-1235). Imported lazily, like every + sibling, so a lone copy of this file (the portability tests' incomplete + install) still loads and still gates.""" + import rulebook_moments # noqa: PLC0415 — beside this file + return rulebook_moments + + +# The wire keys stay here, the source of truth the server's ingest mirrors; +# the projections and the checkout key are rulebook_moments'. WIRE_KEYS = ("fire_id", "rule_id", "rule_version", "session_id", "agent_id", "worktree", "repo", "branch", "tool", "hook_phase", "mode", "dedup_key", "raw_matches_before_fire", "fired_at", "source_message_id", "override_reason", "judge_score", "judge_verdict") _JUDGE_WIRE_KEYS = ("judge_score", "judge_verdict") - -# What the hook observed AFTER a fire (rule-fire-events-spec §2). Facts, never -# verdicts: `receipt` (an ordering rule's green run in this checkout), -# `converted` (a rule's converted_rx matched a command in this session), -# `dismissed` (the person set a rule aside, with the reason), `turn_end` (the -# session finished a turn), `session_end`. The server folds these into each -# fire's outcome; nothing here decides one. EVENT_WIRE_KEYS = ("event_id", "kind", "rule_id", "session_id", "agent_id", "worktree", "repo", "branch", "reason", "at") -# Capture namespaces a conversation id per host: Claude sends the session uuid -# bare (flush_turn.py), Codex sends `codex-<uuid>` (codex_flush.py) and Cursor -# `cursor-<uuid>` (cursor_flush.py). A fire carries the raw hook-payload -# session id, so on Claude it matches the captured conversation by accident and -# on the others it cannot match at all — every Codex fire shows "Not linked -# yet" (ENG-1075). Namespace it HERE, at the wire, and nowhere else: ordering -# state, obligations, dedup keys and state_path all key off the raw id, and -# renaming that mid-session would strand an in-flight session's own state. def wire_session_id(session_id, host): - """The session id as CAPTURE wrote it, so the server can join the two. - - Delegates to `pr_link.conversation_id_for`, which already owns this exact - projection for the PR-link lane — a second copy is how the two lanes come - to disagree about what a Codex session is called, and it already handles - the cases a fresh one forgets (surrounding whitespace, a host spelled - `Codex`, an id that already carries its prefix). - - Imported lazily and on a fail-open path: only the flush lane projects rows, - so the per-call pre/post lanes never pay for the import, and a fire that - cannot be namespaced still ships (unlinked) rather than taking the hook - down. - """ - try: - import pr_link - return pr_link.conversation_id_for(host, session_id) or session_id - except Exception: - return session_id + return _rm().wire_session_id(session_id, host) def wire_row(row): - """The v2 ledger row minus `excerpt` (Phase 1: always stripped — the org - opt-in for excerpts is a server setting the hook does not consult). - - `host` is local-only, like `rulebook_id`: it is not a wire field, it is - how this projection knows which namespace the session id belongs in. - - The two judge keys are sent only on a row whose fire was judged (it has a - `judge_verdict`). A fire nobody judged posts the row shape it always did, - which a server older than the judge columns also accepts. - """ - out = {k: row.get(k) for k in WIRE_KEYS} - if row.get("judge_verdict") is None: - for k in _JUDGE_WIRE_KEYS: - out.pop(k, None) - out["session_id"] = wire_session_id(out.get("session_id"), row.get("host")) - return out + return _rm().wire_row(row) def event_wire_row(row): - """Same projection as `wire_row`, and the same namespacing. + return _rm().event_wire_row(row) - The server folds these into each fire's outcome BY SESSION, so an event - that names the session differently from the fire it answers folds into - nothing. `host` is local-only here too. - """ - out = {k: row.get(k) for k in EVENT_WIRE_KEYS} - out["session_id"] = wire_session_id(out.get("session_id"), row.get("host")) - return out + +def effect_wire_row(row): + return _rm().effect_wire_row(row) def worktree_key(root): - """The 16-hex checkout id every fire and receipt carries — the same key - the ordering engine files its state under, so a receipt run by another - session of this checkout can answer this session's fire on the server.""" + """The 16-hex checkout id every fire and receipt carries — the key the + ordering engine files its state under.""" return hashlib.sha1(root.encode("utf-8")).hexdigest()[:16] if root else None @@ -2919,32 +2699,17 @@ def _read_rows(path, start=0, offsets=None): def _breadcrumb(what, exc): """ledger/.last_error — the one place a silent backstop failure is visible.""" try: - _atomic_json(os.path.join(_ledger_dir(), ".last_error"), - {"at": _now(), "what": what, "error": str(exc)[:300]}) + _rm().breadcrumb(os.path.join(_ledger_dir(), ".last_error"), what, exc, + now=_now, atomic_json=_atomic_json) except Exception: pass def _breadcrumb_clear(what): - """Retract the breadcrumb once ``what``'s own lane has worked again. - - Without this a lane that records no success of its own — the judge — leaves - a single blip standing until some OTHER lane happens to succeed. The judge - runs on PreToolUse and the book is only refetched at SessionStart, so one - 1.5 s timeout mid-session would produce a health banner at the next session - start, long after the lane had recovered. A warning that outlives its cause - is the failure mode this file exists to avoid. - - Only clears a crumb this lane wrote: another lane's failure is still real. - """ - path = os.path.join(_ledger_dir(), ".last_error") + """Retract the crumb once `what`'s own lane worked again (rulebook_moments).""" try: - with open(path, encoding="utf-8") as f: - crumb = json.load(f) - if not isinstance(crumb, dict) or crumb.get("what") != what: - return - os.unlink(path) - except Exception: # no crumb, unreadable, or already gone — all fine + _rm().breadcrumb_clear(os.path.join(_ledger_dir(), ".last_error"), what) + except Exception: pass @@ -2970,6 +2735,10 @@ def load_sent(): LEDGERS = ( ("fires.jsonl", "fires_offset", "/fires", "fires", "fire_id", wire_row), ("events.jsonl", "events_offset", "/fire-events", "events", "event_id", event_wire_row), + # The effect judge's windows (fire_effect.py). Last: a 404 — a server + # without the route, or an org whose `rule_fire_effect` flag is off — + # keeps this watermark and holds the other two back never. + ("effects.jsonl", "effects_offset", "/fire-effects", "effects", "fire_id", effect_wire_row), ) @@ -3114,7 +2883,8 @@ def flush_fires(final=False): L = rulebook_ledger # drop confirmed rows from a ledger past 1 MB L and L.rotate(ldir, [x[:2] for x in LEDGERS], _LEDGER_TAIL_BYTES) sent = load_sent() - plan = [(ledger, pending_batches(sent, ledger)[0]) for ledger in LEDGERS] + plan = [(ledger, pending_batches(sent, ledger)[0]) for ledger in LEDGERS + if not (ledger[0] == EFFECTS_LEDGER and _effects_unsupported(sent))] legacy, legacy_sent = legacy_verdict_batches(sent) if legacy and not any(b for b, _ in legacy): # only orphans past the old watermark: retire them without a POST @@ -3147,6 +2917,25 @@ def flush_fires(final=False): lock.close() +EFFECTS_LEDGER = "effects.jsonl" +EFFECTS_RETRY_S = 3600 # after a 404 on /fire-effects, leave the ledger alone this long + + +def _effects_unsupported(sent): + """True while a recent flush learned the server does not take windows + here — no /fire-effects route (older than ENG-1231), or the org's + `rule_fire_effect` flag is off (the server answers 404 `effect_judging_off` + rather than counting every row rejected, which the ledger would log as a + loss): the effects ledger is left out of the plan rather than POSTed + (and 404ed) on every Stop. The rows wait behind their watermark, so an + org that turns the flag on gets its recent windows judged.""" + try: + at = datetime.fromisoformat(str(sent.get("effects_unsupported_at") or "")) + return (datetime.now(timezone.utc) - at).total_seconds() < EFFECTS_RETRY_S + except Exception: + return False + + def _post_batches(ledger, batches, base, bearer, http): """Ship one ledger's batches; True when every batch was accepted. The per-batch `.sent` write is re-read from disk first so progress another @@ -3158,6 +2947,16 @@ def _post_batches(ledger, batches, base, bearer, http): reply = http.rest(f"{base}{API_PATH}{path}", bearer, "POST", body={body_key: batch}, timeout=FLUSH_TIMEOUT_S) except Exception as exc: # transport/envelope error: retry next flush, + if _fname == EFFECTS_LEDGER and getattr(exc, "status", None) == 404: + # A server without the route (older than ENG-1231), or an + # org with the flag off (404 `effect_judging_off`): not a + # failure of this install, so no breadcrumb for the health + # banner. The rows wait behind their watermark and the ledger + # is skipped for EFFECTS_RETRY_S before it is tried again. + cur = load_sent() + cur["effects_unsupported_at"] = _now() + _atomic_json(_sent_path(), cur) + return True _breadcrumb("flush", exc) # but say so where an operator can look return False if reply.status not in (200, 201, 202): @@ -3185,11 +2984,19 @@ def _post_batches(ledger, batches, base, bearer, http): for r in batch], batch, idkey) else: _log_rejected(rej, batch, idkey) + if _fname == EFFECTS_LEDGER and isinstance(data.get("verdicts"), list): + try: # live repair reads these at the next Stop + import fire_effect # noqa: PLC0415 — beside this file + fire_effect.record_verdicts(_ledger_dir(), data["verdicts"]) + except Exception: + pass accepted += data["accepted"] # only THIS ledger's watermark moves; the other's is whatever is on disk cur[okey] = after[okey] if stall.get("key") == key: cur.pop("stall", None) # an accepted batch clears only ITS OWN marker + if _fname == EFFECTS_LEDGER: + cur.pop("effects_unsupported_at", None) # the route exists after all cur["last_flush_at"] = _now() cur["last_accepted"] = accepted _atomic_json(_sent_path(), cur) # per batch: a later failure keeps this progress @@ -3240,9 +3047,6 @@ def _repo_name(root, gitdir): return os.path.basename(root) -_SEED_MAX_HOPS = 64 # a Write names a new dir a few levels deep, never thousands - - def _under(path, base): """True when `path` is `base` or sits beneath it. `join(base, "")` is the only spelling of the prefix that is right at a POSIX root ("/"), a Windows @@ -3251,103 +3055,167 @@ def _under(path, base): return path == base or path.startswith(os.path.join(base, "")) -def _acted_on_dir(cwd, inp): - """The directory of the file this call acts on, in the SESSION's own path - space, or "" when the payload names none this session may reach. - - Payload data must not steer where the hook looks, so the session cwd is - the trust boundary. Containment is checked TWICE, and both must hold: - - * lexically, on the unresolved path — because that is the path - `repo_info` actually walks up from. A symlink OUTSIDE cwd whose target - is inside it passes a resolved-only check while its lexical parents - still lead somewhere else entirely, which would hand `root` (and so - `git -C root`, which honors a repo's local config) to a checkout the - session never opened; - * and again once symlinks are resolved — so a link UNDER cwd cannot - smuggle the lookup out of it. - - Requiring both also pins the value to ONE path space per session: an - absolute path spelled differently from cwd (/var vs /private/var, an - automounted home) fails the lexical test and falls back to the cwd - answer. That matters because `root` keys OrderingEngine state - (`{rid}@{root}:{branch}`), and one worktree reached two ways would split - into two keys and silently re-arm its ordering rules.""" - if not (isinstance(inp, dict) and cwd): +def _same_dir(a, b): + """True when two spellings name one directory (/tmp vs /private/tmp, a + symlinked home).""" + try: + return os.path.realpath(a) == os.path.realpath(b) + except (OSError, ValueError): + return False + + +def _call_seed(cwd, data): + """The directory this call ACTS in, as the payload names it, or "": the + edited or read file's own directory (anywhere — a session edits across + checkouts), the first file a Codex patch names, or where a Bash command + runs (`call_dir`: its `cd` chain, `git -C`, `~` and `$VAR` expanded).""" + inp = data.get("tool_input") or {} + if not isinstance(inp, dict): return "" - base = os.path.normpath(cwd) for key in ("file_path", "notebook_path"): # each judged on its own: fp = inp.get(key) # a junk file_path must not - if not (isinstance(fp, str) and fp): # hide a good notebook_path + if not (isinstance(fp, str) and fp.strip()) or "\0" in fp: # hide a good notebook_path continue + d = os.path.dirname(fp.replace("\\", "/")) + if not os.path.isabs(d): # relative to the SESSION's cwd, never ours + d = os.path.join(cwd, d) + return os.path.normpath(d) + if data.get("tool_name") == "apply_patch": + # Codex edits name their files inside the patch text, not in file_path. try: - d = os.path.dirname(fp.replace("\\", "/")) - if not os.path.isabs(d): # relative to the SESSION's cwd, never ours - d = os.path.join(cwd, d) - d = os.path.normpath(d) - if not _under(d, base): - continue - probe, hops = d, 0 # a Write may name a directory not created yet - while not os.path.exists(probe) and os.path.dirname(probe) != probe \ - and hops < _SEED_MAX_HOPS: - probe, hops = os.path.dirname(probe), hops + 1 - if _under(os.path.realpath(probe), os.path.realpath(cwd)): - return d - except (OSError, ValueError): # payload strings are untrusted (NUL -> ValueError) - continue + dirs = [os.path.dirname(path) for path, _new, _added in apply_patch_files(inp, cwd)] + except (OSError, ValueError): # payload strings are untrusted + return "" + # The first patched file that sits in a checkout decides. + return next((d for d in dirs if repo_info(d)[0]), dirs[0] if dirs else "") + command = inp.get("command") + if data.get("tool_name") == "Bash" and isinstance(command, str): + try: + return read_cd(cwd, command, repo_info) + except Exception: + return "" return "" +def _write_root(path, repo, root): + """(worktree, path) for a file a Bash command writes, when the file is in + a checkout of `repo` — the call's book judges only its own repo's files — + re-spelled under `root` when that is the same directory; else ("", path).""" + info = repo_info(os.path.dirname(path)) + if not info[0] or info[0] != repo: + return "", path + if root and info[1] != root and _same_dir(info[1], root): + return root, os.path.join(root, os.path.relpath(path, info[1])) + return info[1], path + + +_BOOKS = {} + + +def _file_book(path, repo, root, rules, refresh): + """(rules, worktree, path, at) judging a file a Bash call writes or reads: + the call's book when the file is in a checkout of the call's repo, else + the book of the repo whose checkout holds it (`echo > ../other/x.csv`, + `tee ~/other/app/x.py`), refreshed as for a `cd` there, with `at` = that + checkout's ledger fields. None outside any checkout.""" + info = repo_info(os.path.dirname(path)) + if not info[0]: + return None + if info[0] == repo: + if root and info[1] != root and _same_dir(info[1], root): + return rules, root, os.path.join(root, os.path.relpath(path, info[1])), None + return rules, info[1], path, None + if info[0] not in _BOOKS: + got = load_rules(info[0]) + if refresh: + got = first_fetch(info[0], got[0], got[2], got[3]) # (rules, fetched_at, sources) + maybe_refresh(info[0], got[1]) + _BOOKS[info[0]] = got[0] + return _BOOKS[info[0]], info[1], path, {"repo": info[0], "gitdir": info[2], "branch": info[3], + "worktree": worktree_key(info[1])} + + +def _armed(rules, on): + return any(r.get("on") == on and r.get("status", "active") == "active" for r in rules) + + def repo_of_call(data): """(repo, root, gitdir, branch) for the checkout this CALL works in: the - acted-on file's first, the session cwd's second, else all empty. - - The acted-on path outranks cwd because of the worktree-parent workflow — - an agent running from a directory that CONTAINS many checkouts and editing - files inside them. cwd resolves nothing there, and gating the rulebook on - it alone left every rule silently inert for the whole session while the - edited file sat in a real worktree the entire time. Worktrees themselves - were never the problem: `repo_info` reads the `.git` FILE and `scope_ok` + one it acts in (`_call_seed`) when that is a checkout, else the session + cwd's, else all empty. + + What the call touches outranks cwd. A session in MemHub-Backend that runs + `cd ~/xtrace/agent-plugins-internal && git push`, `git -C ../xmem …`, or + edits a file in another repo is judged by THAT repo's book, scope and + worktree key: judged by the session's, the target's scoped rules never + fired (28 of 56 misses in the 2026-10 audit) and the session's misfired + on foreign work. Worktrees resolve through the `.git` FILE and `scope_ok` maps the gitdir back to the main checkout, so a rule scoped to the repo - matches from any of its worktrees once the walk starts in the right place. + matches from any of its worktrees. + + `root` keys ordering state (`{rid}@{root}:{branch}`), so a target that is + the cwd's own checkout spelled another way (/tmp vs /private/tmp) answers + with the cwd's spelling: one worktree, one key. A target that is no + checkout (`cd /tmp && …`, a file outside any repo) keeps the cwd answer. - A Bash call carries no path and keeps the cwd answer, so a non-git cwd - with nothing acted on stays silent exactly as before.""" + A Bash call with neither in a checkout takes the first file it writes or + reads that is (`cat ~/repos/app/x.py` acts on `app`); else it is + projectless (`main`).""" cwd = data.get("cwd") or os.getcwd() - seed = _acted_on_dir(cwd, data.get("tool_input") or {}) - if not seed and data.get("tool_name") == "apply_patch": - # Codex edits name their files inside the patch text, not in file_path, - # so the parent-folder workflow above resolved nothing on Codex and its - # edit rules stayed inert. The first patched file that passes the same - # containment checks decides the checkout. - try: - for path, _new, _added in apply_patch_files(data.get("tool_input") or {}, cwd): - seed = _acted_on_dir(cwd, {"file_path": path}) - if seed: - break - except (OSError, ValueError): # payload strings are untrusted - seed = "" + here = repo_info(cwd) + seed = _call_seed(cwd, data) if seed: info = repo_info(seed) if info[0]: + if here[0] and info[1] != here[1] and _same_dir(info[1], here[1]): + return here return info - info = repo_info(cwd) - if info[0]: - return info - # Desktop may normalize exec_command to Bash and omit workdir. A leading - # literal `cd ... &&` is explicit context; never guess from sibling repos, - # mid-command changes, or shell expansions. Keep repository-rooted behavior. - inp = data.get("tool_input") or {} - command = inp.get("command") if isinstance(inp, dict) else None - if data.get("tool_name") == "Bash" and isinstance(command, str): - match = _CD_PREFIX.match(command) - if match and match.group(0).rstrip().endswith("&&"): - target = next((g for g in match.groups() if g), "") - if target and not any(c in target for c in "$`*?[]{}~\\\n\r"): - root = command_root(cwd, command) - if root: - return repo_info(root) - return info + if not here[0]: + for path in _bash_targets(cwd, data): + info = repo_info(os.path.dirname(path)) + if info[0]: + return info + return here + + +def _bash_targets(cwd, data): + """Files a Bash call writes, then reads (override stripped); [] otherwise.""" + if data.get("tool_name") != "Bash": + return [] + cmd = (data.get("tool_input") or {}).get("command") + if not isinstance(cmd, str) or not cmd: + return [] + try: + found = find_override(cmd) + if found: + cmd = strip_override(cmd, found) + return ([p for p, _ in bash_write_targets(cwd, cmd)] + + [p for p, _ in bash_reads(cwd, cmd)]) + except Exception: + return [] + + +def projectless_enabled(): + """A call in no checkout is judged on every host, but not under a + repos.json (repo_allow.session_enabled).""" + if repo_allow is None: + return True + try: + return not repo_allow.restricted() + except Exception: + return True + + +def call_enabled(root): + """repo_allow's verdict for the checkout at `root`: True with no + allowlist, with no shim, or on any failure — a broken list must not + silence every rule.""" + if repo_allow is None or not root: + return True + try: + return bool(repo_allow.enabled(root)) + except Exception: + return True _HEAD_REF = re.compile(r"^ref:\s*refs/heads/(.+)$") @@ -3551,40 +3419,45 @@ def save_state(p, st, before=None): r"|Caveat: The messages below" r"|Base directory for this skill:" r"|Another Claude session sent a message:)") - -# Another agent's message: a subagent's hand-back, or another session's -# SendMessage, delivered as a user-role prompt. The same sentence -# `harness_extract` already treats as harness. Nobody typed it, so unlike a -# wake-up (`<task-notification>`, which `prompt` rules exist to speak at) it -# is no moment for a prompt rule either: a rule written for "the person asked -# X" fired on a subagent's report that merely mentioned X. _AGENT_MESSAGE_RX = re.compile(r"\s*Another Claude session sent a message:") +def harness_prompt(text): + """True when this UserPromptSubmit carries something the HARNESS wrote: + only what a PERSON typed may arm an obligation. Every marker is + STRUCTURED (a wrapper tag, a sentence the client emits verbatim); an + English prefix is never one — `Approach this as` silenced a real + question once. Suppressing a genuine prompt is the worse error.""" + return bool(_HARNESS_PROMPT_RX.match(text or "")) + + def agent_message(text): - """True when this UserPromptSubmit is another agent's message — see - `_AGENT_MESSAGE_RX`. It neither arms an obligation (it is harness text) - nor fires a `prompt` rule.""" + """Another agent's message (a subagent's hand-back, another session's + SendMessage) delivered as a user-role prompt: nobody typed it, so it + neither arms an obligation nor fires a `prompt` rule.""" return bool(_AGENT_MESSAGE_RX.match(text or "")) -def harness_prompt(text): - """True when this UserPromptSubmit carries something the HARNESS wrote. - - Only what a PERSON typed may arm an obligation: a rule armed by the word - "staging" in a prompt exists because someone said they were asking about - staging, and a skill body or a loop wake-up that happens to contain the - word said nothing of the kind. It would arm the rule for the rest of the - session with nobody having asked for it. - - Every marker here is STRUCTURED — a wrapper tag, or a sentence the client - emits verbatim. An ordinary English prefix is not a marker however - harness-like it reads: `Approach this as` was one, and it silenced - "Approach this as a staging incident", a real person asking exactly the - question a staging rule exists for. Suppressing a genuine prompt is the - worse error of the two, because the rule then never arms and nothing - anywhere says why.""" - return bool(_HARNESS_PROMPT_RX.match(text or "")) +EXPANSION_WINDOW_S = 5.0 # a command expanded this recently is what the next prompt submit carries + + +def _expansion_path(session): + return state_path(session)[:-len(".json")] + ".expansion.json" + + +def note_expansion(session, data): + """UserPromptExpansion (ENG-1235): the command the person typed, recorded + so the submit that carries its body is not taken for their words.""" + if session: + _rm().note_expansion(_expansion_path(session), data, now=_now, atomic_json=_atomic_json) + + +def recent_expansion(session): + """True when a command expanded within EXPANSION_WINDOW_S.""" + return bool(session) and _rm().recent_expansion( + _expansion_path(session), age_s=_age_s, window=EXPANSION_WINDOW_S) + + def session_scoped(rule): @@ -3668,16 +3541,18 @@ def arm_obligations(rules, repo, gitdir, session, event, prompt=""): return armed +def failure_result(data): + """PostToolUseFailure's `error` as the post lane's (result text, response) + (ENG-1236, _rm().failure_result).""" + return _rm().failure_result(data) + + def result_text(resp): - if resp is None: - return "" - if isinstance(resp, str): - return resp - if isinstance(resp, dict): - parts = [v for k in ("stderr", "stdout", "output", "error", "text") - if isinstance((v := resp.get(k)), str) and v] - return "\n".join(parts) if parts else json.dumps(resp, ensure_ascii=False) - return str(resp) + """A tool response as text (rulebook_moments.result_text).""" + try: + return _rm().result_text(resp) + except ImportError: + return "" if resp is None else str(resp) BRAND = "XTrace" @@ -3705,10 +3580,6 @@ def result_text(resp): _OVERRIDE_TOKEN_RX = re.compile(r"RULEBOOK_OVERRIDE=(?:'[^']*'|\"[^\"]*\"|\S*)\s*") -def _segment_op(tok): - return bool(tok) and all(ch in ";&|(" for ch in tok) - - def _raw_token(line, reason): """The raw text of the assignment on `line` whose shlex value is `reason` (quotes intact, trailing space included), or None.""" @@ -4009,50 +3880,11 @@ def turn_end_at(session, started, transcript_path=None): def _turn_end_from_transcript(tp, before): - """The timestamp (local ISO, microseconds) of the latest assistant - `end_turn` record in the transcript's tail written no later than - `before`, or None. Reads the tail the way `message_id_of` does.""" + """The latest assistant `end_turn` record no later than `before`, read + from the transcript's tail (rulebook_moments.turn_end_from_transcript).""" if not tp: return None - try: - limit = datetime.fromisoformat(before) - except Exception: - return None - try: - with open(tp, "rb") as f: - f.seek(0, os.SEEK_END) - end = f.tell() - window = _TAIL_START - while True: - start = max(0, end - window) - f.seek(start) - lines = f.read(end - start).splitlines() - if start: - lines = lines[1:] - for raw in reversed(lines): - if not raw.strip(): - continue - try: - rec = json.loads(raw) - except Exception: - continue - if rec.get("type") != "assistant": - continue - msg = rec.get("message") - if not isinstance(msg, dict) or msg.get("stop_reason") != "end_turn": - continue - try: - ts = datetime.fromisoformat(str(rec.get("timestamp")).replace("Z", "+00:00")) - except Exception: - continue - if ts.tzinfo is None or ts > limit: - continue - return ts.astimezone().isoformat(timespec="microseconds") - if start == 0 or window >= _TAIL_MAX: - return None - window *= 4 - except Exception: - return None + return _rm().turn_end_from_transcript(tp, before, _TAIL_START, _TAIL_MAX) def _just_before(iso): @@ -4089,39 +3921,13 @@ def _now(): def message_id_of(data): """The transcript record the tool call belongs to — the server resolves it - to the stored message. Reads the tail of the JSONL rather than the whole - file: these grow to megabytes and this runs on a 5 s hook budget. Any - problem returns None; the link is optional and never blocks a fire.""" + to the stored message (rulebook_moments.message_id_of). None on any + problem; the link is optional and never blocks a fire.""" tp = str(data.get("transcript_path") or "") if not tp: return None try: - with open(tp, "rb") as f: - f.seek(0, os.SEEK_END) - end = f.tell() - window = _TAIL_START - while True: - start = max(0, end - window) - f.seek(start) - lines = f.read(end - start).splitlines() - # A non-zero start almost certainly cut the first line in half. - if start: - lines = lines[1:] - for raw in reversed(lines): - if not raw.strip(): - continue - try: - rec = json.loads(raw) - except Exception: - continue - if rec.get("type") not in _MESSAGE_TYPES: - continue - uid = rec.get("uuid") - if isinstance(uid, str) and uid: - return uid - if start == 0 or window >= _TAIL_MAX: - return None - window *= 4 + return _rm().message_id_of(tp, _MESSAGE_TYPES, _TAIL_START, _TAIL_MAX) except Exception: return None @@ -4170,6 +3976,8 @@ def log_fires(ctx, rules, *, hook_phase, mode, excerpt, raw_counts=None, dedup_k for r in rules: fid = str(uuid.uuid4()) ids[r["id"]] = fid + c = dict(ctx, **{k: r["_at"][k] for k in ("repo", "branch", "worktree")}) \ + if r.get("_at") else ctx verdict = (judge or {}).get(r["id"]) judged = {"judge_score": verdict.get("p_fit"), "judge_verdict": verdict.get("verdict")} if verdict else {} @@ -4178,13 +3986,15 @@ def log_fires(ctx, rules, *, hook_phase, mode, excerpt, raw_counts=None, dedup_k "rulebook_id": r.get("_rulebook_id"), "rule_version": ctx["rule_version"] if r.get("_version") is None else r["_version"], "session_id": ctx["session"], "agent_id": ctx["agent_id"], - "worktree": ctx.get("worktree"), + "worktree": c.get("worktree"), # Local-only (see wire_row): which host's namespace this # session id belongs to, recorded at fire time because the # flush that ships it may be a different invocation. "host": ctx.get("host"), "source_message_id": ctx.get("source_message_id"), - "repo": ctx["repo"], "branch": ctx["branch"], "tool": ctx["tool"], + "tool_use_id": ctx.get("tool_use_id"), # local-only; see wire_row + "title": str(r.get("_label") or r.get("title") or "")[:120], # local-only + "repo": c["repo"], "branch": c["branch"], "tool": ctx["tool"], "hook_phase": hook_phase, "mode": mode, "dedup_key": (dedup_keys or {}).get(r["id"]), "raw_matches_before_fire": (raw_counts or {}).get(r["id"]), @@ -4318,6 +4128,16 @@ def _dismissal_lines(set_aside, ambiguous): # deliberately NOT counted against POSTURE_BUDGET_CHARS — that budget bounds # rule CONTENT, and this framing is what makes the content usable. Do not let # it past ~500 chars without deciding that trade again. +# A Codex session outside every checkout: its unscoped rules still apply, but a +# repo's own rules need the call to name that repo. +CODEX_NO_REPO = ( + "MemHub Rulebook: this task starts outside a Git repository. " + "Codex may omit exec_command.workdir from hook payloads, so " + "formal rules cannot identify that checkout. For repository " + "shell commands, use an explicit, shell-quoted absolute " + "`cd <repo> && ...` prefix (including when workdir is set), " + "or start the task in the repository.") + SESSION_PREAMBLE = ( "These are your team's engineering rules — standing instructions from your teammates, " "carrying the same weight as this repo's CLAUDE.md. Follow them as you would CLAUDE.md: " @@ -4378,9 +4198,11 @@ def books_line(carried): return ("- _From " + " · ".join(shown))[:ROSTER_MAX_CHARS] + "._" -def session_digest(rules, repo, gitdir, ctx): +def session_digest(rules, repo, gitdir, ctx, note=None): in_scope = [r for r in rules if scope_ok(r, repo, gitdir) and r.get("status", "active") == "active"] if not in_scope: + if note: + emit("SessionStart", note) return # Spec §2: at most MAX_POSTURE session rules and ~2k tokens per scope. # ONE budget across every book the caller is in (container spec §13.1) — @@ -4430,6 +4252,8 @@ def session_digest(rules, repo, gitdir, ctx): roster = books_line(posture + active) if roster: lines.append(roster) + if note: + lines.append(note) emit("SessionStart", "\n".join(lines)) if posture: log_fires(ctx, posture, hook_phase="session", mode="advise", excerpt="") @@ -4439,6 +4263,21 @@ def session_digest(rules, repo, gitdir, ctx): raw_counts={r["id"]: 0 for r in cut}) +PROMPT_BLOCK_AFTER_S = 600 # prompt lane: block on a fetch only past this; younger books refresh detached + + +def refresh_for_prompt(repo, rules, fetched_at, sources): + """The prompt lane's refresh (ENG-1235, _rm().refresh_for_prompt): + detached and served as cached under PROMPT_BLOCK_AFTER_S, waited for past it.""" + if os.environ.get("MEMHUB_RULEBOOK_FETCH", "1") == "0": + return rules, fetched_at, sources + out = _rm().refresh_for_prompt( + _age_s(fetched_at), blocking=lambda: refresh_if_stale(repo, rules, fetched_at, sources), + detached=lambda: maybe_refresh(repo, fetched_at), + block_after=PROMPT_BLOCK_AFTER_S, refresh_after=REFRESH_AFTER_S) + return out if out is not None else (rules, fetched_at, sources) + + def refresh_if_stale(repo, rules, fetched_at, sources): """(rules, fetched_at, sources), with the book re-fetched first when it is old enough to be wrong. @@ -4505,6 +4344,8 @@ def prompt_lane(rules, repo, root, gitdir, branch, session, ctx, data, text): probes = Probes(root, branch, transcript_path=data.get("transcript_path"), agent_id=ctx["agent_id"]) fired, dedup_keys = [], {} + marks = {"fired": list(st["fired"]), "counts": dict(st["counts"]), + "spec_pending": dict(st["spec_pending"])} for r in live: rid = r["id"] scope = r.get("fire_scope", "session") @@ -4557,6 +4398,7 @@ def prompt_lane(rules, repo, root, gitdir, branch, session, ctx, data, text): for r in cut: log_fires(ctx, [r], hook_phase="prompt", mode="suppressed", excerpt="", raw_counts=raw, dedup_keys=dedup_keys, fired_at=fired_at) + unfire(st, r, dedup_keys.get(r["id"]), marks, reset_raw=True) # cut, not spent for r in shown: st["raw"][r["id"]] = 0 save_state(sp, st, before=before) @@ -4577,8 +4419,10 @@ def main(): # book_path in a skill would drift from the one the hook reads, and the # test would then doctor a file nothing loads. repo = sys.argv[2] if len(sys.argv) > 2 else "" - if not repo.strip(): - print("usage: rulebook_hook.py book-path <repo> [cwd]", file=sys.stderr) + if repo == "--projectless": # the book a call in no checkout reads (repo="") + repo = "" + elif not repo.strip(): + print("usage: rulebook_hook.py book-path <repo>|--projectless [cwd]", file=sys.stderr) return 2 # The optional cwd answers "where does a call made THERE read its book # from" — which is what a forward test needs, and what tells it whether @@ -4586,6 +4430,14 @@ def main(): set_active_base(sys.argv[3] if len(sys.argv) > 3 else "") print(book_path(repo)) return 0 + if mode == "expansion": # UserPromptExpansion: record the command (ENG-1235) + try: + data = json.loads(sys.stdin.read() or "{}") + except Exception: + data = {} + if isinstance(data, dict): + note_expansion(str(data.get("session_id") or ""), data) + return 0 if mode == "flush": started = _now() # before anything else: the Stop happened no later than this final = "final" in sys.argv[2:] @@ -4635,10 +4487,22 @@ def main(): # `codex-<uuid>` while its own turn_end event said `<uuid>`, and the # server folds events into fires by (org, session_id), so every # turn_end folded into nothing. + ended_at = turn_end_at(session, started, data.get("transcript_path")) log_event({"session": session, "agent_id": None, "repo": repo or None, "branch": branch or None, "worktree": worktree_key(root), - "host": _host_arg()}, kind, - at=turn_end_at(session, started, data.get("transcript_path"))) + "host": _host_arg()}, kind, at=ended_at) + # The effect judge's windows (fire_effect.py) for the turn that ended. + try: + import fire_effect # noqa: PLC0415 — beside this file + sdir = os.path.dirname(state_path(session)) + if kind == "session_end": + fire_effect.on_session_end(_ledger_dir(), sdir, session, + data.get("transcript_path"), judge_redact) + else: + fire_effect.on_turn_end(_ledger_dir(), sdir, session, + data.get("transcript_path"), ended_at, judge_redact) + except Exception: + pass flush_fires(final=final) if _rbp: _rbp.drain_legacy(globals(), final) # the pre-keying shared ledger @@ -4667,18 +4531,20 @@ def main(): # of the shared one. No claim (the overwhelming case) → the real base. set_active_base(cwd) repo, root, gitdir, branch = repo_of_call(data) - if not repo: # never invent a repository for a projectless call - if mode == "session" and _host_arg() == "codex": - print(json.dumps({"hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": ( - "MemHub Rulebook: this task starts outside a Git repository. " - "Codex may omit exec_command.workdir from hook payloads, so " - "formal rules cannot identify that checkout. For repository " - "shell commands, use an explicit, shell-quoted absolute " - "`cd <repo> && ...` prefix (including when workdir is set), " - "or start the task in the repository." - )}})) + # Projectless: judged on the `repo=""` book, which holds only rules with + # empty `scope_repos`, so they bind wherever the session runs. + note = CODEX_NO_REPO if not repo and mode == "session" and _host_arg() == "codex" else None + if not repo and not projectless_enabled(): + if note: + emit("SessionStart", note) + return 0 + # The repository allowlist (repo_allow.py): with a repos.json, a call whose + # checkout it leaves out gets no rule, no fetch and no state — judged by + # the CALL's checkout, so a session in a parent folder of several repos is + # served in the listed ones and silent in the rest. The Claude hook + # command gates the session-wide lanes the same way; this is the + # per-call half. + if not call_enabled(root): return 0 if mode == "fetch": fetch_book(repo) @@ -4689,16 +4555,23 @@ def main(): show_upgrade(repo, session, "PreToolUse") return 0 rules, rule_version, fetched_at, sources = load_rules(repo) + # No book at all for this call's repo: fetch it now, bounded, or the + # first call that names a repo is judged on nothing (`first_fetch`). + if mode in ("pre", "codex-pre"): + rules, fetched_at, sources = first_fetch(repo, rules, fetched_at, sources) # A Codex bridge trusted before SessionStart was wired (three handlers) # never ran the session lane's fetch. Refresh before a first/stale # pre-call so such an install cannot silently miss a gate. if codex_pre and _age_s(fetched_at) >= REFRESH_AFTER_S: rules, fetched_at, sources = refresh_if_stale(repo, rules, fetched_at, sources) tool = data.get("tool_name", "") - ctx = {"session": session, "agent_id": agent_id_of(data), "repo": repo, + ctx = {"session": session, "agent_id": agent_id_of(data), "repo": repo or None, # projectless: null, as session events "branch": branch, "tool": tool, "rule_version": rule_version, "source_message_id": message_id_of(data), "worktree": worktree_key(root), - "host": _host_arg()} + "host": _host_arg(), + # Local-only, like `host`: the call this fire is anchored on, so the + # Stop lane can build the effect judge's window (fire_effect.py). + "tool_use_id": str(data.get("tool_use_id") or "") or None} if mode == "prompt": # UserPromptSubmit. It arms ordering rules silently: anything printed # here is injected above the person's own words, and an arming is not @@ -4714,8 +4587,19 @@ def main(): # subject again. The pre lane's detached refresh cannot help — it # lands after the prompt it needed to see. text = str(data.get("prompt") or "") - if text and not harness_prompt(text): - rules, fetched_at, sources = refresh_if_stale(repo, rules, fetched_at, sources) + # A command body is not the person's words: the host's wrappers say + # so by regex, UserPromptExpansion for every command (fire_effect.py + # attaches only the person's own reaction to the waiting windows). + typed = bool(text) and not harness_prompt(text) and not recent_expansion(session) + try: + import fire_effect # noqa: PLC0415 — beside this file + fire_effect.on_prompt(_ledger_dir(), os.path.dirname(state_path(session)), session, + text, _now(), judge_redact, + person=typed and not agent_message(text)) + except Exception: + pass + if typed: + rules, fetched_at, sources = refresh_for_prompt(repo, rules, fetched_at, sources) arm_obligations(rules, repo, gitdir, session, "prompt", prompt=text) elif text: # A wake-up is not worth making the session wait for the server: @@ -4760,7 +4644,7 @@ def main(): pass if show_upgrade(repo, session, "SessionStart"): return 0 - session_digest(rules, repo, gitdir, ctx) + session_digest(rules, repo, gitdir, ctx, note) arm_obligations(rules, repo, gitdir, session, "session") return 0 if mode == "pre": @@ -4814,14 +4698,20 @@ def main(): if mode == "pre" and (tool in EDIT_TOOLS or patched) else {}) rtext = result_text(data.get("tool_response")) if mode == "post" else "" resp = data.get("tool_response") if (mode == "post" and tool == "Bash") else None + if mode == "post" and str(data.get("hook_event_name") or "") == "PostToolUseFailure": + rtext, resp = failure_result(data) # a failed call's `error`, same lane (ENG-1236) ordering = None dedup_keys = {} # The events this call is. The tool call itself, always; and for a Bash - # call that wrote files, one synthetic Write per file, so an edit rule sees - # a heredoc / write_text() / sed -i the way it sees the Write tool. The - # edit lane of `evaluate` is a pre-phase lane and the ordering engine arms - # on a post-phase edit, so a synthetic event carries both phases. + # call that writes files, one synthetic Write per file, so an edit rule + # sees a heredoc / write_text() / sed -i the way it sees the Write tool. + # Twice over: in the PRE lane from the command line (`bash_write_targets`, + # via "bash-pre": before the write, so it can still be refused), and in + # the POST lane from the disk (`bash_written_files`, via "bash": the + # backstop for writes the command line does not show). The edit lane of + # `evaluate` is a pre-phase lane and the ordering engine arms on a + # post-phase edit, so a post synthetic event carries both phases. # Synthetic edits go FIRST: inside the command the writes happened before # its final segment, so a `python fix.py && pytest` must read as edit, # then receipt — the other order would arm an obligation the same call @@ -4829,6 +4719,7 @@ def main(): real = {"tool": tool, "phase": mode, "order_phase": mode, "cmd": cmd, "fp": fp, "body": body, "added": added, "rtext": rtext, "resp": resp, "via": None, "read": None} events = [] + pre_written = [] # (rule id, real path) the pre lane fired for this Bash call if tool == "Bash": marks = st.setdefault("bash_t0", {}) call_key = str(data.get("tool_use_id") or "last") @@ -4836,20 +4727,43 @@ def main(): marks[call_key] = time.time() for k in list(marks)[:-BASH_EDIT_MARKS_KEPT]: marks.pop(k, None) + # Each written file is judged by the book of the repo it is in: + # the call's, or another checkout's (`echo > ../other/x.csv`). + for path, text in bash_write_targets(cwd, cmd) if cmd else []: + book = None if os.path.isdir(path) else _file_book(path, repo, root, rules, True) + if book and _armed(book[0], "edit"): + events.append({"tool": "Write", "phase": "pre", "order_phase": "pre", + "cmd": "", "fp": book[2], "body": text, "added": None, + "rtext": "", "resp": None, "via": "bash-pre", "read": None, + "root": book[1], "rules": book[0], "at": book[3]}) else: t0 = marks.pop(call_key, None) if t0 is None and call_key != "last": t0 = marks.pop("last", None) + done = st.setdefault("bash_pre", {}) + pre_written = done.pop(call_key, None) or (done.pop("last", None) if call_key != "last" else None) or [] wants_edits = any(r.get("on") in ("edit", "ordering") and r.get("status", "active") == "active" for r in rules) if t0 is not None and wants_edits: - for path, is_new in bash_written_files(root, cmd, t0): - text = read_edit_body(path, is_new) - if text is None: - continue - events.append({"tool": "Write", "phase": "pre", "order_phase": "post", - "cmd": "", "fp": path, "body": text, "rtext": "", - "resp": None, "via": "bash"}) + # Every checkout of this repo the command ran or wrote in, and + # the session's own: found from the command, not its spelling. + also = [] + for d in bash_dirs(cwd, cmd) + [os.path.dirname(p) for p, _ in bash_write_targets(cwd, cmd)] + [cwd]: + w = _write_root(os.path.join(d, "_"), repo, root)[0] + if w and w not in also: + also.append(w) + scans = [(root, also, rules, None)] + for p, _ in bash_write_targets(cwd, cmd): # another repo's checkout, its own book + book = _file_book(p, repo, root, rules, False) + if book and book[3] and _armed(book[0], "edit") and all(x[0] != book[1] for x in scans): + scans.append((book[1], (), book[0], book[3])) + for sroot, salso, srules, sat in scans: + for path, is_new, wt in bash_written_files(sroot, cmd, t0, salso): + text = read_edit_body(path, is_new) + if text is not None: + events.append({"tool": "Write", "phase": "pre", "order_phase": "post", + "cmd": "", "fp": path, "body": text, "rtext": "", "resp": None, + "via": "bash", "root": wt, "rules": srules, "at": sat}) # A patch is an edit tool call, not a file a command already wrote: its # events keep the call's own phase, exactly like Claude's Edit. The edit # lane judges it at pre, where a gate can still refuse it (via None), and @@ -4865,15 +4779,18 @@ def main(): # a cat/head/tail/less/more/sed segment names, in the pre phase, because # unlike a written file these have not happened yet and CAN be refused. # Measured only when a read rule is armed: a line count is one file walk. - wants_reads = mode == "pre" and any(r.get("on") == "read" and r.get("status", "active") == "active" - for r in rules) + # A file in another repo's checkout is judged by that repo's read rules. + wants_reads = mode == "pre" and _armed(rules, "read") if wants_reads and tool in READ_TOOLS and fp: real["read"] = read_facts(fp, offset=inp.get("offset"), limit=inp.get("limit")) - elif wants_reads and tool == "Bash" and cmd: + elif mode == "pre" and tool == "Bash" and cmd: for path, pulled in bash_reads(cwd, cmd): - events.append({"tool": "Read", "phase": "pre", "order_phase": "pre", "cmd": cmd, - "fp": path, "body": "", "rtext": "", "resp": None, - "via": "bash-read", "read": read_facts(path, pulled=pulled)}) + book = _file_book(path, repo, root, rules, True) or (rules, root, path, None) + if _armed(book[0], "read"): + events.append({"tool": "Read", "phase": "pre", "order_phase": "pre", "cmd": cmd, + "fp": book[2], "body": "", "rtext": "", "resp": None, + "via": "bash-read", "read": read_facts(book[2], pulled=pulled), + "root": book[1], "rules": book[0], "at": book[3]}) # Conversions (rule-fire-events-spec §2): did this call run the action a # rule's `converted_rx` names? Posted as a `converted` event for EVERY @@ -4966,8 +4883,10 @@ def main(): fired_on = {} # rule id → the event that fired it (its path, for the ledger and the line) for ev in events: etool, ephase, ecmd, efp, ebody = ev["tool"], ev["phase"], ev["cmd"], ev["fp"], ev["body"] - for r in rules: - if r.get("on") in ("session", "anchor") or not scope_ok(r, repo, gitdir) \ + at = ev.get("at") # a file in ANOTHER repo's checkout: that book, its edit/read rules + for r in rules if ev.get("rules") is None else ev["rules"]: + if r.get("on") in ("session", "anchor") or (at and r.get("on") not in ("edit", "read")) \ + or not scope_ok(r, *((at["repo"], at["gitdir"]) if at else (repo, gitdir))) \ or r.get("status", "active") != "active": # draft = not armed (§6) continue rid = r["id"] @@ -4975,10 +4894,14 @@ def main(): continue if r.get("on") == "ordering": + # A write the command has not made yet arms nothing: the post + # lane's disk scan arms on the write that happened. + if ev.get("via") == "bash-pre": + continue # Path scope says which EDITS arm the obligation. The receipt # and the gated call are Bash and carry no path, so they are # never filtered here — only an edit outside the scope is. - if etool in EDIT_TOOLS and not path_in_scope(r, efp, root): + if etool in EDIT_TOOLS and not path_in_scope(r, efp, ev.get("root") or root): continue if stale_arming(st, r): # Armed for an earlier version of this rule. The rule that @@ -5016,18 +4939,22 @@ def main(): fired_on[rid] = ev continue - if not path_in_scope(r, efp if etool in EDIT_TOOLS + READ_TOOLS else "", root): + if not path_in_scope(r, efp if etool in EDIT_TOOLS + READ_TOOLS else "", + ev.get("root") or root): + continue + # The pre lane already fired this rule on this file of this call. + if ev.get("via") == "bash" and [rid, os.path.realpath(efp)] in pre_written: continue scope = r.get("fire_scope", "session") # A gate blocks EVERY matching call — never deduped (§5.3), in # any lane the hook can refuse: a Bash command, an edit the tool # has not written yet, or a read — the Read tool's own, or one a # Bash segment is about to make. `via` guards the difference: a - # synthetic EDIT event is a file a command ALREADY wrote, so it - # cannot be refused and keeps its rule's ordinary dedup; a - # synthetic READ has not happened, and refusing the command is - # refusing the read. - if ephase == "pre" and r.get("mode") == "gate" and ev.get("via") in (None, "bash-read") \ + # post-lane synthetic EDIT ("bash") is a file a command ALREADY + # wrote, so it cannot be refused and keeps its rule's ordinary + # dedup; a pre-lane one ("bash-pre") and a synthetic READ have not + # happened, and refusing the command is refusing the write or read. + if ephase == "pre" and r.get("mode") == "gate" and ev.get("via") in (None, "bash-read", "bash-pre") \ and ((etool == "Bash" and r.get("on") == "bash") or (etool in EDIT_TOOLS and r.get("on") == "edit") or (etool in READ_TOOLS and r.get("on") == "read")): @@ -5060,7 +4987,7 @@ def main(): st["spec_pending"][json.dumps([rid, probe_root, probe_branch])] = {"root": probe_root, "branch": probe_branch, "spec_dir": spec_given.get("spec_dir", "docs/specs"), "paths": [spec.path for spec, _ in hits]} r = dict(r) r["text"], r["_spec_detail"] = _spec_untouched_text(r["text"], hits) - fired_now.append(r) + fired_now.append(dict(r, _at=at) if at else r) fired_on[rid] = ev # One instant for everything this call records: its fires, and the @@ -5080,7 +5007,7 @@ def _excerpt(r): records the file, prefixed so a reader can count how many edits arrive through Bash versus the Write tool.""" ev = fired_on.get(r["id"]) - if ev and ev.get("via") == "bash": + if ev and ev.get("via") in ("bash", "bash-pre"): return f"bash-edit {ev['fp']}" if ev and ev.get("via") == "bash-read": return f"bash-read {ev['fp']}" @@ -5136,11 +5063,13 @@ def _dismiss(dismissals): return 0 # §5.3: which of this call's fires are GATES. Only a call the hook sees - # BEFORE it runs can be blocked — a pre-hook Bash command, or a pre-hook + # BEFORE it runs can be blocked — a pre-hook Bash command, a pre-hook # edit, which `evaluate` matches against `tool_input`, the content the tool - # is about to write. A fire from the synthetic lane (`via == "bash"`, a file - # discovered after a command wrote it) is post-hoc whatever its rule says, - # so it advises: there is nothing left to refuse. + # is about to write, or a file the Bash command is about to write + # (`via == "bash-pre"`): refusing the command refuses the write. A fire + # from the post-lane synthetic lane (`via == "bash"`, a file discovered + # after a command wrote it) is post-hoc whatever its rule says, so it + # advises: there is nothing left to refuse. def _gateable(r): if mode != "pre" or r.get("mode") != "gate": return False @@ -5148,7 +5077,8 @@ def _gateable(r): if via == "bash": return False if tool == "Bash": - return r.get("on") in ("bash", "ordering") or (r.get("on") == "read" and via == "bash-read") + return r.get("on") in ("bash", "ordering") or (r.get("on") == "read" and via == "bash-read") \ + or (r.get("on") == "edit" and via == "bash-pre") if tool in READ_TOOLS: return r.get("on") == "read" # A patch at pre has not applied yet, so it can be refused like an Edit. @@ -5258,11 +5188,13 @@ def _where(r): knows which file a Write was, but a heredoc's target is buried in the command it just ran.""" ev = fired_on.get(r["id"]) - if not ev or ev.get("via") not in ("bash", "bash-read"): + if not ev or ev.get("via") not in ("bash", "bash-pre", "bash-read"): return "" - path = ev["fp"] - if root and path.startswith(root.rstrip("/") + "/"): - path = os.path.relpath(path, root) + path, eroot = ev["fp"], ev.get("root") or root + if eroot and path.startswith(eroot.rstrip("/") + "/"): + path = os.path.relpath(path, eroot) + if ev.get("via") == "bash-pre": + return f" _(in `{path}`, which that command writes)_" if ev.get("via") == "bash-read": n = (ev.get("read") or {}).get("lines") return f" _(`{path}`, {n} lines, read by that command)_" if n is not None \ @@ -5379,6 +5311,20 @@ def _where(r): for r in cut: # the per-call cap has a cost; make it visible, never silent log_fires(ctx, [r], hook_phase=mode, mode="suppressed", excerpt=_excerpt(r), raw_counts=raw, dedup_keys=dedup_keys, fired_at=fired_at, judge=judged) + # …and never a spend: a rule the cap cut was not shown, so it keeps + # its dedup and can fire on a later call — marked fired here, a + # session-scoped rule that lost the cap once was gone for the session. + unfire(st, r, dedup_keys.get(r["id"]), marks, reset_raw=True) + if mode == "pre" and tool == "Bash": + # The writes this call's pre lane fired on, so the post lane's disk + # scan does not fire the same rule on the same file a second time. + wrote = [[r["id"], os.path.realpath(fired_on[r["id"]]["fp"])] for r in shown + if (fired_on.get(r["id"]) or {}).get("via") == "bash-pre"] + if wrote: + done = st.setdefault("bash_pre", {}) + done[str(data.get("tool_use_id") or "last")] = wrote + for k in list(done)[:-BASH_EDIT_MARKS_KEPT]: + done.pop(k, None) for r in shown: st["raw"][r["id"]] = 0 if ids.get(r["id"]) and r["id"] in same_call: diff --git a/plugins/memhub/scripts/rulebook_mod_cli.py b/plugins/memhub/scripts/rulebook_mod_cli.py index 3409c154..92cfb9b6 100644 --- a/plugins/memhub/scripts/rulebook_mod_cli.py +++ b/plugins/memhub/scripts/rulebook_mod_cli.py @@ -64,6 +64,8 @@ "rulebook_id": "b-1", optional, local only "rule_version": "7", optional "excerpt": "git push -f", optional, local only, cut to 160 + "tool_use_id": "tu-0", optional, local only: the call this fire + is anchored on (fire_effect.py's window) "dedup_key": "...", "raw_matches_before_fire": 0, "override_reason": null, "fired_at": "<iso, µs>", "judge_verdict": "fit", "judge_score": 0.91} optional @@ -98,7 +100,35 @@ {"env": "staging", "plugin_root": "...", "base": "...", "repo": "MemHub-Backend", "root": "/path", "branch": "main", "worktree": "<16 hex>", "book": "<base>/book/MemHub-Backend-1a2b3c4d.json", - "ledger": "<base>/ledger", "state": "<base>/state"} + "ledger": "<base>/ledger", "state": "<base>/state", + "enabled": true, "restricted": false, "declined": false, + "identity": "XTraceAI/MemHub-Backend"} + +``enabled`` is the repository allowlist's answer for that checkout +(``repo_allow.enabled``; with ``--repo`` and no checkout, null: unknown), +and ``restricted`` whether a ``repos.json`` exists at all. The mod claims no +lane and shows no companion where ``enabled`` is false; the Python lanes +read the same file. ``declined`` (ENG-1253) is whether the person answered +No to enabling MemHub in that checkout (repos.json ``declined``), so the +status strip never re-derives the file: it asks only where ``restricted`` +is true and ``enabled`` and ``declined`` both false. ``identity`` is the +checkout's name as repos.json lists it (``org/name`` from ``origin``, or the +bare directory name; "" outside a checkout) — the ``repo`` field is the +hook's own shorter name, which a rule's scope is matched against. + +answer +------ + python3 scripts/rulebook_mod_cli.py answer --env staging --cwd /path --say yes|no + +The strip's **1 Yes** / **2 No** (ENG-1253): ``yes`` lists the checkout +holding ``--cwd`` (``repo_allow.allow``, what ``/memhub:repos add .`` +does, a decline cleared), ``no`` records it as declined +(``repo_allow.decline``: dormant, and never asked again). stdout, one JSON +line: ``{"identity": "org/name", "enabled": bool, "declined": bool, +"path": "<repos.json>"}``. Exit 2 when ``--cwd`` is outside any checkout +(nothing to list), 1 when the file cannot be written. The Python hooks read +the file per call, so they follow at the next call; the mod flips its own +state from the answer. ``repo``/``root``/``branch``/``worktree`` are ``repo_of_call({"cwd": cwd})`` — what the hook lanes resolve for a call made there — and ``book`` is @@ -124,7 +154,7 @@ HOST = "claude" EVENT_KINDS = ("receipt", "converted", "dismissed", "turn_end", "session_end") _CTX_KEYS = ("repo", "branch", "worktree", "tool", "agent_id", "source_message_id", - "rule_version") + "tool_use_id", "rule_version") def _out(obj) -> None: @@ -287,10 +317,59 @@ def cmd_paths(args) -> int: worktree = rh.worktree_key(root) root, branch = root or None, branch or None book = rh.book_path(repo) if repo else None + enabled, restricted, declined, identity = _allowed(root, args.cwd) + # `base` is the ACTIVE base: a §4b forward-test claim covering `cwd` + # redirects it to the test's private copy. `real_base` is the install's + # own, where that claim file lives (the mod reads it per call, lanes.ts). _out({"env": args.env, "plugin_root": mod_lanes.plugin_root(), "base": base, + "real_base": rh.BASE, "repo": repo or "", "root": root, "branch": branch, "worktree": worktree, "book": book, "ledger": os.path.join(base, "ledger"), - "state": os.path.join(base, "state")}) + "state": os.path.join(base, "state"), + "enabled": enabled, "restricted": restricted, + "declined": declined, "identity": identity}) + return 0 + + +def _allowed(root, cwd): + """(enabled, restricted, declined, identity) from repo_allow for the + checkout `root`, or for `cwd` when the call named no checkout (then a cwd + outside any checkout is dormant, as hook_entry treats a session there). + (None, False, False, "") when neither is known or the module is + unavailable.""" + try: + import repo_allow # noqa: PLC0415 + restricted = repo_allow.restricted() + where = root or cwd + identity = repo_allow.identity(*repo_allow.checkout(where)) if where else "" + declined = bool(identity) and repo_allow.is_declined(identity) + if root: + return bool(repo_allow.enabled(root)), restricted, declined, identity + if cwd: + return bool(repo_allow.session_enabled(cwd)), restricted, declined, identity + return None, restricted, declined, identity + except Exception: # noqa: BLE001 — unknown is an answer the mod treats as enabled + return None, False, False, "" + + +# ── answer ────────────────────────────────────────────────────────────────── + +def cmd_answer(args) -> int: + import repo_allow # noqa: PLC0415 + root, gitdir = repo_allow.checkout(args.cwd) + identity = repo_allow.identity(root, gitdir) if root else "" + if not identity: + print("rulebook_mod_cli answer: %s is not inside a Git checkout; nothing to list" + % args.cwd, file=sys.stderr) + return 2 + try: + path = repo_allow.allow(identity) if args.say == "yes" else repo_allow.decline(identity) + except OSError as exc: + print("rulebook_mod_cli answer: could not write %s: %s" + % (repo_allow.allowlist_path(), exc), file=sys.stderr) + return 1 + _out({"identity": identity, "enabled": repo_allow.allows(identity), + "declined": repo_allow.is_declined(identity), "path": path}) return 0 @@ -299,12 +378,15 @@ def cmd_paths(args) -> int: def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="rulebook_mod_cli.py") sub = ap.add_subparsers(dest="cmd") - for name in ("api-info", "log", "paths"): + for name in ("api-info", "log", "paths", "answer"): p = sub.add_parser(name) p.add_argument("--env", required=True, choices=sorted(mod_lanes.ENV_VARS)) if name == "paths": p.add_argument("--cwd", default="") p.add_argument("--repo", default="") + if name == "answer": + p.add_argument("--cwd", required=True) + p.add_argument("--say", required=True, choices=("yes", "no")) try: args = ap.parse_args(argv) except SystemExit as exc: @@ -317,7 +399,8 @@ def main(argv=None) -> int: print(f"rulebook_mod_cli: --env {args.env} but this install is " f"{mine or 'unidentifiable'}", file=sys.stderr) return 2 - return {"api-info": cmd_api_info, "log": cmd_log, "paths": cmd_paths}[args.cmd](args) + return {"api-info": cmd_api_info, "log": cmd_log, "paths": cmd_paths, + "answer": cmd_answer}[args.cmd](args) if __name__ == "__main__": diff --git a/plugins/memhub/scripts/rulebook_moments.py b/plugins/memhub/scripts/rulebook_moments.py new file mode 100644 index 00000000..878bc13e --- /dev/null +++ b/plugins/memhub/scripts/rulebook_moments.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""The hook's newer moments, kept out of `rulebook_hook.py` (ENG-1235, ENG-1236). + +`rulebook_hook.py` runs as `__main__` and sits at the directory export's +per-file size limit, so what these need from it (its state dir, its clock, +its atomic writer) is passed in; nothing here imports the hook. + +* the base-branch probe cache: `Probes.base()` memoised ACROSS hook calls in + a per-checkout file, keyed on HEAD, a `stat` fingerprint of the refs and + the base the command names — a dozen git subprocesses (~250 ms) on every + call that matched a `given` rule become one `rev-parse` and a few stats; +* the prompt lane's refresh policy: a book merely a minute old is refreshed + detached and served as cached; only an old or missing book is waited for; +* UserPromptExpansion: the command the person typed, recorded so the submit + that carries its expansion is known to be a body, not their words; +* the wire projections, the Bash-result helpers, the breadcrumb and the + transcript tail readers: pure helpers the hook calls through wrappers; +* PostToolUseFailure: the host's `error` as the post lane's result text and + response, so output rules fire on a failed command and a failure is never + an ordering receipt. + +Stdlib only; every function fails open. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re + +PROBE_CACHE_TTL_S = 600 # a cached base answer is trusted this long at most +PROMPT_BLOCK_AFTER_S = 600 # prompt lane: block on a fetch only past this; younger books refresh detached +EXPANSION_WINDOW_S = 5.0 # a command expanded this recently is what the next prompt submit carries + +_EXIT_LINE = re.compile(r"\A\s*Exit code (\d+)[ \t]*\n?") + + +# ── a Bash result ─────────────────────────────────────────────────────────── + +def result_text(resp): + if resp is None: + return "" + if isinstance(resp, str): + return resp + if isinstance(resp, dict): + parts = [v for k in ("stderr", "stdout", "output", "error", "text") + if isinstance((v := resp.get(k)), str) and v] + return "\n".join(parts) if parts else json.dumps(resp, ensure_ascii=False) + return str(resp) + + +def bash_ok(resp, *, strict=False): + """Did the Bash call succeed? Uses exit_code when the harness supplies it. + Without one, the text proxy (same as the transcript replayer) is a guess a + command's own output could forge — so `strict=True` (used for GATE-mode + receipts) returns False unless an explicit exit_code says 0.""" + if resp is None: # no result at all is never a receipt + return False + if isinstance(resp, dict): + if isinstance(resp.get("exit_code"), int): + return resp["exit_code"] == 0 + if resp.get("is_error") or resp.get("isError"): + return False + if strict: + return False + txt = result_text(resp) + # text proxy, anchored to pytest/traceback vocabulary — a green run whose + # output merely mentions "error:" must not be mistaken for red + return not re.search( + r"(^|\n)(FAILED|ERROR)\b|\b\d+ (failed|errors?)\b|\nTraceback \(most recent call last\)" + r"|(^|\n)npm ERR!|(^|\n)error(\[E\d+\])?:", txt) + + +# ── the breadcrumb ────────────────────────────────────────────────────────── + +def breadcrumb(path, what, exc, *, now, atomic_json): + """`path` (ledger/.last_error) — the one place a silent backstop failure is visible.""" + try: + atomic_json(path, {"at": now(), "what": what, "error": str(exc)[:300]}) + except Exception: + pass + + +def breadcrumb_clear(path, what): + """Retract the breadcrumb once ``what``'s own lane has worked again. + + Without this a lane that records no success of its own — the judge — leaves + a single blip standing until some OTHER lane happens to succeed. The judge + runs on PreToolUse and the book is only refetched at SessionStart, so one + 1.5 s timeout mid-session would produce a health banner at the next session + start, long after the lane had recovered. A warning that outlives its cause + is the failure mode this file exists to avoid. + + Only clears a crumb this lane wrote: another lane's failure is still real. + """ + try: + with open(path, encoding="utf-8") as f: + crumb = json.load(f) + if not isinstance(crumb, dict) or crumb.get("what") != what: + return + os.unlink(path) + except Exception: # no crumb, unreadable, or already gone — all fine + pass + + +# ── the wire ──────────────────────────────────────────────────────────────── + +WIRE_KEYS = ("fire_id", "rule_id", "rule_version", "session_id", "agent_id", "worktree", + "repo", "branch", "tool", "hook_phase", "mode", "dedup_key", + "raw_matches_before_fire", "fired_at", "source_message_id", "override_reason", + "judge_score", "judge_verdict") +_JUDGE_WIRE_KEYS = ("judge_score", "judge_verdict") + +# What the hook observed AFTER a fire (rule-fire-events-spec §2). Facts, never +# verdicts: `receipt` (an ordering rule's green run in this checkout), +# `converted` (a rule's converted_rx matched a command in this session), +# `dismissed` (the person set a rule aside, with the reason), `turn_end` (the +# session finished a turn), `session_end`. The server folds these into each +# fire's outcome; nothing here decides one. +EVENT_WIRE_KEYS = ("event_id", "kind", "rule_id", "session_id", "agent_id", "worktree", + "repo", "branch", "reason", "at") + + +# Capture namespaces a conversation id per host: Claude sends the session uuid +# bare (flush_turn.py), Codex sends `codex-<uuid>` (codex_flush.py) and Cursor +# `cursor-<uuid>` (cursor_flush.py). A fire carries the raw hook-payload +# session id, so on Claude it matches the captured conversation by accident and +# on the others it cannot match at all — every Codex fire shows "Not linked +# yet" (ENG-1075). Namespace it HERE, at the wire, and nowhere else: ordering +# state, obligations, dedup keys and state_path all key off the raw id, and +# renaming that mid-session would strand an in-flight session's own state. +def wire_session_id(session_id, host): + """The session id as CAPTURE wrote it, so the server can join the two. + + Delegates to `pr_link.conversation_id_for`, which already owns this exact + projection for the PR-link lane — a second copy is how the two lanes come + to disagree about what a Codex session is called, and it already handles + the cases a fresh one forgets (surrounding whitespace, a host spelled + `Codex`, an id that already carries its prefix). + + Imported lazily and on a fail-open path: only the flush lane projects rows, + so the per-call pre/post lanes never pay for the import, and a fire that + cannot be namespaced still ships (unlinked) rather than taking the hook + down. + """ + try: + import pr_link + return pr_link.conversation_id_for(host, session_id) or session_id + except Exception: + return session_id + + +def wire_row(row): + """The v2 ledger row minus `excerpt` (Phase 1: always stripped — the org + opt-in for excerpts is a server setting the hook does not consult). + + `host` is local-only, like `rulebook_id`: it is not a wire field, it is + how this projection knows which namespace the session id belongs in. + + The two judge keys are sent only on a row whose fire was judged (it has a + `judge_verdict`). A fire nobody judged posts the row shape it always did, + which a server older than the judge columns also accepts. + """ + out = {k: row.get(k) for k in WIRE_KEYS} + if row.get("judge_verdict") is None: + for k in _JUDGE_WIRE_KEYS: + out.pop(k, None) + out["session_id"] = wire_session_id(out.get("session_id"), row.get("host")) + return out + + +def event_wire_row(row): + """Same projection as `wire_row`, and the same namespacing. + + The server folds these into each fire's outcome BY SESSION, so an event + that names the session differently from the fire it answers folds into + nothing. `host` is local-only here too. + """ + out = {k: row.get(k) for k in EVENT_WIRE_KEYS} + out["session_id"] = wire_session_id(out.get("session_id"), row.get("host")) + return out + + +def effect_wire_row(row): + """A window as the server takes it (fire_effect.WIRE_KEYS); the server + finds the fire by id, so nothing here is namespaced.""" + import fire_effect # noqa: PLC0415 — beside this file + return fire_effect.wire_row(row) + + + +# ── the probe cache ───────────────────────────────────────────────────────── + + +def refs_fingerprint(gitdir): + """What `base()` depends on besides HEAD, read with `stat` only: the + checkout's HEAD file, and the common dir's packed refs, FETCH_HEAD (a + fetch rewrites it) and the loose-ref directories (a branch created or + deleted). None when there is no gitdir — then nothing is cached.""" + if not gitdir: + return None + common = gitdir + try: + with open(os.path.join(gitdir, "commondir"), encoding="utf-8") as f: + common = os.path.normpath(os.path.join(gitdir, f.read().strip())) + except OSError: + pass + parts = [] + for path in (os.path.join(gitdir, "HEAD"), os.path.join(common, "packed-refs"), + os.path.join(common, "FETCH_HEAD"), + os.path.join(common, "refs", "remotes", "origin"), + os.path.join(common, "refs", "heads")): + try: + parts.append(str(os.stat(path).st_mtime_ns)) + except OSError: + parts.append("-") + return ":".join(parts) + + +def cached(path, name, key, compute, *, now, age_s, atomic_json, ttl=PROBE_CACHE_TTL_S): + """`compute()` memoised in the JSON file at `path` under `name`, valid + while the stored key equals `key` and the entry is younger than `ttl`. + A corrupt or unreadable file is the cold path; a write that fails is + silent. `key` None → not cached.""" + if key is not None: + try: + with open(path, encoding="utf-8") as f: + hit = (json.load(f) or {}).get(name) + if isinstance(hit, dict) and hit.get("key") == key and age_s(hit.get("at")) < ttl: + return hit.get("value") + except Exception: + pass + value = compute() + if key is not None: + try: + cur = {} + try: + with open(path, encoding="utf-8") as f: + cur = json.load(f) or {} + except Exception: + cur = {} + cur[name] = {"key": key, "at": now(), "value": value} + os.makedirs(os.path.dirname(path), exist_ok=True) + atomic_json(path, cur) + except Exception: + pass + return value + + +# ── the prompt lane's refresh ─────────────────────────────────────────────── + + +def refresh_for_prompt(age, *, blocking, detached, block_after=PROMPT_BLOCK_AFTER_S, refresh_after=60): + """Which refresh a prompt gets for a book `age` seconds old: `blocking()` + past `block_after` (or with no book at all, age inf), `detached()` past + `refresh_after`, nothing for a fresh book. The prompt lane used to block + 310-390 ms (a 304 costs the round trip too) whenever the book was a + minute old, which is every prompt after a pause; the pre lane already + refreshes in the background every minute during work. What this gives + up: a prompt-armed rule activated in the last ten minutes misses this + prompt.""" + if age >= block_after: + return blocking() + if age >= refresh_after: + detached() + return None + + +# ── UserPromptExpansion ───────────────────────────────────────────────────── + + +def note_expansion(path, data, *, now, atomic_json): + """Record the command the person typed and its expansion type at `path`.""" + try: + atomic_json(path, {"command": str(data.get("command_name") or "")[:200], + "type": str(data.get("expansion_type") or "")[:40], + "prompt": str(data.get("prompt") or "")[:400], + "at": now()}) + except Exception: + pass + + +def recent_expansion(path, *, age_s, window=EXPANSION_WINDOW_S): + """True when a command expanded within `window` seconds: the prompt now + being submitted is its body. The two events' order is not documented, + so this is a window, not a handshake; the harness-prompt regex still + applies when the submit lands first.""" + try: + with open(path, encoding="utf-8") as f: + rec = json.load(f) + return isinstance(rec, dict) and age_s(rec.get("at")) < window + except Exception: + return False + + +# ── the transcript tail ───────────────────────────────────────────────────── + + +def tail_records(path, start_window, max_window): + """Parsed JSONL records from the end of `path`, newest first, reading a + growing tail (`start_window` bytes, ×4 up to `max_window`) rather than + the whole file: transcripts grow to megabytes and the hook has a 5 s + budget. A consumer returns as soon as it has its answer; exhausting the + generator means the whole allowed tail held nothing.""" + with open(path, "rb") as f: + f.seek(0, os.SEEK_END) + end = f.tell() + window = start_window + while True: + start = max(0, end - window) + f.seek(start) + lines = f.read(end - start).splitlines() + if start: + lines = lines[1:] # a non-zero start cut the first line in half + for raw in reversed(lines): + if not raw.strip(): + continue + try: + rec = json.loads(raw) + except Exception: + continue + if isinstance(rec, dict): + yield rec + if start == 0 or window >= max_window: + return + window *= 4 + + +def message_id_of(transcript_path, message_types, start_window, max_window): + """The uuid of the last transcript record whose `type` is one of + `message_types`; None on any problem.""" + try: + for rec in tail_records(transcript_path, start_window, max_window): + if rec.get("type") in message_types: + uid = rec.get("uuid") + if isinstance(uid, str) and uid: + return uid + except Exception: + pass + return None + + +def turn_end_from_transcript(transcript_path, before, start_window, max_window): + """The timestamp (local ISO, microseconds) of the latest assistant + `end_turn` record written no later than `before` (ISO), or None.""" + try: + from datetime import datetime + limit = datetime.fromisoformat(before) + for rec in tail_records(transcript_path, start_window, max_window): + if rec.get("type") != "assistant": + continue + msg = rec.get("message") + if not isinstance(msg, dict) or msg.get("stop_reason") != "end_turn": + continue + try: + ts = datetime.fromisoformat(str(rec.get("timestamp")).replace("Z", "+00:00")) + except Exception: + continue + if ts.tzinfo is None or ts > limit: + continue + return ts.astimezone().isoformat(timespec="microseconds") + except Exception: + pass + return None + + +# ── PostToolUseFailure ────────────────────────────────────────────────────── + + +def failure_result(data): + """PostToolUseFailure's `error` as the post lane's (result text, response). + The host writes `Exit code N` as the error's first line for a shell + command; a content_rx anchored with `^` reads the first line, so the + error body goes first and the exit line last. The response carries the + exit code and `is_error`, so `bash_ok` never takes a failure for a + receipt.""" + err = str(data.get("error") or "") + m = _EXIT_LINE.match(err) + body = err[m.end():] if m else err + code = int(m.group(1)) if m else None + text = body + (f"\nExit code {code}" if m else "") + resp = {"stderr": body, "is_error": True} + if code is not None: + resp["exit_code"] = code + return text, resp + + +def user_turns_of(tp, max_bytes, chars, keep): + """What the person typed this session, oldest first. A transcript `user` + record is also how tool results and injected context arrive, so this keeps + only real prompts: no `toolUseResult`, no `tool_result` block, no `isMeta` + row, no compaction summary. A substring pre-filter keeps it to one + json.loads per candidate line. None when there is no transcript — and + None never satisfies a `user` predicate.""" + try: + size = os.path.getsize(tp) + turns = [] + with open(tp, "rb") as f: + if size > max_bytes: + f.seek(size - max_bytes) + f.readline() # the cut line + for raw in f: + if b'"user"' not in raw or b'"type"' not in raw: + continue + try: + rec = json.loads(raw) + except Exception: + continue + if rec.get("type") != "user" or rec.get("isMeta") \ + or rec.get("isCompactSummary") or "toolUseResult" in rec: + continue + c = (rec.get("message") or {}).get("content") + if isinstance(c, str): + text = c + elif isinstance(c, list): + if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c): + continue + text = "\n".join((b.get("text") or "") for b in c + if isinstance(b, dict) and b.get("type") == "text") + else: + continue + text = text.strip() + if text: + turns.append(text[:chars]) + return turns[-keep:] + except Exception: + return None diff --git a/plugins/memhub/scripts/rulebook_shell.py b/plugins/memhub/scripts/rulebook_shell.py new file mode 100644 index 00000000..434c20ce --- /dev/null +++ b/plugins/memhub/scripts/rulebook_shell.py @@ -0,0 +1,656 @@ +"""Shell lexing for the rulebook hook: quoting, heredocs, separators, and +what a Bash command touches — the directory each segment runs in +(`call_dir`, `bash_dirs`) and the files it writes, with their text +(`bash_write_targets`). + +Pure functions of the command text and an environment mapping: no git, no +I/O beyond `os.path` resolution. Split out of `rulebook_hook.py` (which +imports every name back, so `rulebook_hook.<name>` still works) to keep that +file under the release tree's per-file size limit; the TS mod's twin is +`mod/engine/shell.ts`. +""" +import os +import re +import shlex + + +# A Bash call that wrote files is an edit too (see `bash_written_files`). +BASH_EDIT_MAX_FILES = 40 # more than this in one call is a generator, not an edit + + +BASH_EDIT_MAX_BYTES = 512 * 1024 # per file; bigger is data, not source + + +# ── shell-only segment ────────────────────────────────────────────────────── +_HD_OPEN = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_]\w*)\1") # delimiter must be a word, so `x << 2` is a shift + + +def shell_only(cmd): + """Drop heredoc BODY lines; keep every shell line, including commands + after a terminator (`commit -F - <<'MSG' … && git push`). + Known edge: a bit-shift in a multi-line command can arm a bogus skip.""" + out, skip_until = [], None + for line in cmd.split("\n"): + if skip_until is not None: + if line.strip() == skip_until: + skip_until = None + continue + out.append(line) + m = _HD_OPEN.search(line) + if m: + skip_until = m.group(2) + return "\n".join(out) + + +# Quoted spans, blanked to spaces IN PLACE. `shlex` would tokenise properly +# but throws on the half-quoted strings real commands contain, and this only +# needs the contents neutralised, not the tokens. Length-preserving on +# purpose: the separator scans below find operators in the blanked copy and +# slice the ORIGINAL at those offsets, so quoted text survives intact. +# A double-quoted span honours backslash escapes; a single-quoted span has +# none in POSIX shell. +_QUOTED_SINGLE = r"'[^']*'" + + +_QUOTED_DOUBLE = r'"(?:\\.|[^"\\])*"' + + +_QUOTED_RX = re.compile(_QUOTED_SINGLE + "|" + _QUOTED_DOUBLE) + + +# `&&` and `||` first, so the lone-operator alternatives only see what is +# left. A standalone `&` backgrounds the command to its left and the next one +# runs anyway — `gh pr view -R other & git push` is TWO commands. The +# lookarounds keep redirection out: `2>&1`, `cmd >&2`, `cmd &> log`. +_SEPARATOR_RX = re.compile(r"&&|\|\||;|\n|\||(?<![>&])&(?![>&])") + + +_ESCAPE_RX = re.compile(r"\\.", re.S) # a backslash escape, outside quotes + + +# A `#` starts a comment at the start of a word: after whitespace, an +# operator, or a grouping paren/brace. NOT after `{` — `${#files}` is a length. +_COMMENT_RX = re.compile(r"(?<![^\s;&|()}])#[^\n]*") + + +def blank_quoted(text): + """`text` with the contents of quoted spans — and every backslash escape + outside them — replaced by spaces, character for character, so every + offset still points at the same place. + + A `\\|` is not a pipe: an escape is data, just as quoting is.""" + return _COMMENT_RX.sub(lambda m: " " * len(m.group(0)), blank_syntax(text)) + + +def blank_syntax(text): + """Quotes and escapes blanked, comments LEFT IN PLACE, length preserved. + + Exposed for `strip_comments`. Escapes become a NON-space placeholder, + because `\\ ` joins two words in bash (`.title\\ #literal` is no comment).""" + blanked = _QUOTED_RX.sub(lambda m: m.group(0)[0] + " " * (len(m.group(0)) - 2) + + m.group(0)[-1], text or "") + return _ESCAPE_RX.sub("\x01\x01", blanked) + + +# ── a leading assignment is not part of the command ───────────────────────── +# +# `FOO=1 git push` execs `git push`, and a rule has to read it the same way, +# or an ANCHORED rule (`^git\s+push`) is silently bypassed by any prefix — +# including a refused `RULEBOOK_OVERRIDE=` that `strip_override` leaves in. +_ASSIGN_TOKEN_RX = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|\"[^\"]*\"|\S*)\s*") + + +_ASSIGN_NAME_RX = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") + + +def strip_leading_assignments(shell): + """`shell` with the env assignments that BEGIN a segment removed, byte for + byte identical everywhere else. + + Tokenised per line by `find_override`'s shlex walk, so a quoted + assignment is data and an unparseable line is handed back untouched. A + run is stripped whole (`FOO=1 BAR=2 git push` -> `git push`).""" + out = [] + for line in shell.split("\n"): + try: + lex = shlex.shlex(line, posix=True, punctuation_chars=True) + lex.whitespace_split = True + toks = list(lex) + except ValueError: # unbalanced quoting: not ours to rewrite + out.append(line) + continue + targets, at_start = [], True + for tok in toks: + if at_start and _ASSIGN_NAME_RX.match(tok): + targets.append(tok) # stay at_start: assignments come in runs + continue + at_start = _segment_op(tok) + stripped = line + for val in targets: + for m in _ASSIGN_TOKEN_RX.finditer(stripped): + try: + if shlex.split(m.group(0))[0] != val: + continue + except (ValueError, IndexError): + continue + stripped = stripped[:m.start()] + stripped[m.end():] + break + out.append(stripped) + return "\n".join(out) + + +_VAR_RX = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))") + + +_INTERP_RX = re.compile(r"^(?:python[0-9.]*|node)$") + + +def expand_path(token, here, env=None): + """`token` as the shell would name the path — `~`, `$HOME`, `${VAR}` from + `env` (default: this process's environment), relative to `here` — or "" + when it cannot be named without running anything: a command + substitution, a glob, an unset variable, another user's `~name`, or a + relative path with no known `here`.""" + env = os.environ if env is None else env + if not isinstance(token, str) or not token or "$(" in token \ + or any(c in token for c in "`*?[]\n\r\0"): + return "" + missing = [] + + def var(m): + v = env.get(m.group(1) or m.group(2)) + if v is None: + missing.append(m.group(0)) + return "" + return v + t = _VAR_RX.sub(var, token) + if missing or "$" in t or "{" in t or "}" in t: + return "" + if t == "~" or t.startswith("~/"): + home = env.get("HOME") or "" + if not home: + return "" + t = home + t[1:] + elif t.startswith("~"): + return "" + if not os.path.isabs(t): + if not here: + return "" + t = os.path.join(here, t) + return os.path.normpath(t) + + +def _call_segments(cmd): + """[(separator before it, segment)] of the command's shell text (heredoc + bodies dropped), split where `blank_quoted` shows a separator, so one + inside a quoted argument stays data.""" + text = shell_only(cmd or "") + blank = blank_quoted(text) + out, pos, sep = [], 0, "" + for m in _SEPARATOR_RX.finditer(blank): + out.append((sep, text[pos:m.start()])) + sep, pos = m.group(0), m.end() + out.append((sep, text[pos:])) + return out + + +def _walk_call(cwd, cmd, env): + """([segment facts], final dir) for each command of `cmd` that is not a + directory change: {"dir", "toks", "sep", "text"}. `dir` follows `cd` / + `pushd` / `popd` and a `git -C`; "" when it cannot be named.""" + here = os.path.normpath(cwd) if cwd else "" + stack, out = [], [] + for sep, seg in _call_segments(cmd): + text = strip_leading_assignments(seg.strip()).strip().lstrip("({ \t").strip() + if not text: + continue + try: + toks = shlex.split(text) + except ValueError: + if re.match(r"(?:cd|pushd|popd)\b", text): + here = "" + continue + toks = [t for t in toks if t not in (")", "}", "))")] + if not toks: + continue + if toks[0] in ("cd", "pushd"): + args = [t for t in toks[1:] if not (t.startswith("-") and len(t) > 1)] + if toks[0] == "pushd": + stack.append(here) + if not args: + here = expand_path("~", "", env) if toks[0] == "cd" else "" + elif args[0] == "-": + here = "" + else: + here = expand_path(args[0], here, env) + continue + if toks[0] == "popd": + here = stack.pop() if stack else "" + continue + d = here + if os.path.basename(toks[0]) == "git": + i = 1 + while i < len(toks) and toks[i].startswith("-"): + t = toks[i] + if t == "-C" and i + 1 < len(toks): + d = expand_path(toks[i + 1], d, env) + i += 2 + elif t.startswith("-C") and len(t) > 2: + d = expand_path(t[2:], d, env) + i += 1 + elif t == "-c" and i + 1 < len(toks): + i += 2 + else: + i += 1 + out.append({"dir": d, "toks": toks, "sep": sep, "text": text}) + return out, here + + +def call_dir(cwd, cmd, env=None): + """The directory a Bash call acts in: its first command's, after any + `cd`/`pushd` chain and `git -C`; with no command but a `cd`, where that + leaves it. "" when it cannot be named (the caller keeps the cwd).""" + env = os.environ if env is None else env + segs, final = _walk_call(cwd, cmd, env) + return segs[0]["dir"] if segs else final + + +def bash_dirs(cwd, cmd, env=None): + """Every directory a Bash call's commands run in (known ones), in order.""" + env = os.environ if env is None else env + segs, final = _walk_call(cwd, cmd, env) + out = [] + for d in [s["dir"] for s in segs] + [final]: + if d and d not in out: + out.append(d) + return out + + +def heredoc_bodies(cmd): + """Each heredoc's body, in order — the lines `shell_only` drops.""" + out, skip, body = [], None, [] + for line in (cmd or "").split("\n"): + if skip is not None: + if line.strip() == skip: + out.append("\n".join(body)) + skip = None + else: + body.append(line) + continue + m = _HD_OPEN.search(line) + if m: + skip, body = m.group(2), [] + if skip is not None: + out.append("\n".join(body)) + return out + + +# `> f`, `>> f`, `1> f`, `&> f`, `>| f` — stdout into a file. `2> f` is a log, +# not an edit; `>&2` names no file. +_REDIR_OUT_RX = re.compile(r"(?<![0-9<>&])(?:1?>>?|&>>?|>\|)[ \t]*(?:'([^']*)'|\"([^\"]*)\"|([^\s;&|<>()]+))") + + +# No backreferences (the TS port runs these through pyre): each quote style +# is its own alternative, and a path is group 1 or 2. +_QUOTED_PATH = r"(?:'([^'\n]+)'|\"([^\"\n]+)\")" + + +_PY_OPEN_W_RX = re.compile(r"\bopen\(\s*" + _QUOTED_PATH + + r"\s*,\s*(?:mode\s*=\s*)?[rRbBuUfF]?(?:'[^'\n]*[wax+][^'\n]*'|\"[^\"\n]*[wax+][^\"\n]*\")") + + +_PY_PATH_W_RX = re.compile(r"\bPath\(\s*" + _QUOTED_PATH + r"\s*\)\s*\.write_(?:text|bytes)\(") + + +_PY_ASSIGN_RX = re.compile(r"\b([A-Za-z_]\w*)\s*=\s*(?:(?:pathlib\.)?Path\(\s*)?" + _QUOTED_PATH) + + +_JS_WRITE_RX = re.compile(r"\b(?:writeFileSync|appendFileSync|writeFile|appendFile)\(\s*" + _QUOTED_PATH) + + +_PY_LITERAL_RX = re.compile(r"\"\"\"([\s\S]*?)\"\"\"|'''([\s\S]*?)'''|\"((?:\\.|[^\"\\\n])*)\"|'((?:\\.|[^'\\\n])*)'") + + +_MODE_LITERAL_RX = re.compile(r"^(?:[rwabxt+]{1,3}|utf-?8)$", re.I) + + +# The text being REPLACED is not written: the first argument of `.replace(`, +# of a `rep(old, new)`-style helper, the first of an `(old, new)` pair, and a +# name bound to a literal and then passed as that first argument. +_REPLACER = r"(?:\.replace|\b_?(?:rep|replace|swap|sub)\w*)\(" + + +_OLD_TAIL_RX = re.compile(r"(?:" + _REPLACER + r"|(?:^|[\[,=\s])\()\s*$") + + +_OLD_NAME_RX = re.compile(_REPLACER + r"\s*([A-Za-z_]\w*)\s*,") + + +def _code_writes(code): + """(paths a Python / Node snippet opens for writing, the text it writes): + the paths of `open(p, 'w')`, `Path(p).write_text(`, a name bound to a path + and then written, `fs.writeFileSync(p`; the text is the snippet's string + literals other than those paths, mode / encoding names, and the text a + `.replace(old, new)` takes out (`_OLD_TAIL_RX`).""" + paths = [m.group(1) or m.group(2) for rx in (_PY_OPEN_W_RX, _PY_PATH_W_RX, _JS_WRITE_RX) + for m in rx.finditer(code)] + for m in _PY_ASSIGN_RX.finditer(code): + name = re.escape(m.group(1)) + if re.search(r"\b" + name + r"\s*\.write_(?:text|bytes)\(", code) or re.search( + r"\bopen\(\s*" + name + r"\s*,\s*(?:mode\s*=\s*)?[rRbBuUfF]?['\"][^'\"\n]*[wax+]", code): + paths.append(m.group(2) or m.group(3)) + if not paths: + return [], "" + lits = [] + olds = {m.group(1) for m in _OLD_NAME_RX.finditer(code)} + old_bound = re.compile(r"\b(?:" + "|".join(re.escape(n) for n in sorted(olds)) + r")\s*=\s*$") if olds else None + for m in _PY_LITERAL_RX.finditer(code): + before = code[max(0, m.start() - 80):m.start()] + if _OLD_TAIL_RX.search(before) or (old_bound and old_bound.search(before)): + continue + if m.group(1) is not None or m.group(2) is not None: + lit = m.group(1) if m.group(1) is not None else m.group(2) + else: + lit = (m.group(3) if m.group(3) is not None else m.group(4)).replace("\\n", "\n") + if lit and lit not in paths and not _MODE_LITERAL_RX.match(lit): + lits.append(lit) + return paths, "\n".join(lits) + + +def _s_replacements(expr): + """The replacement text of each `s<d>pat<d>repl<d>` in a sed / perl expression.""" + out = [] + for m in re.finditer(r"(?:^|[;\s}0-9$/])s([^\w\s\\])", expr or ""): + delim, i, parts, cur = m.group(1), m.end(), [], [] + while i < len(expr) and len(parts) < 2: + c = expr[i] + if c == "\\" and i + 1 < len(expr): + cur.append(expr[i + 1] if expr[i + 1] == delim else expr[i:i + 2]) + i += 2 + continue + if c == delim: + parts.append("".join(cur)) + cur = [] + else: + cur.append(c) + i += 1 + if len(parts) == 2: + out.append(parts[1]) + return out + + +def _inplace_edit(toks): + """(files, written text) for `sed -i …` / `perl -pi …`, else None.""" + name = os.path.basename(toks[0]) + args, exprs, pos, inplace, i = toks[1:], [], [], False, 0 + while i < len(args): + a = args[i] + if a in ("-e", "--expression") and i + 1 < len(args): + exprs.append(args[i + 1]) + i += 2 + continue + if a.startswith("--expression="): + exprs.append(a.split("=", 1)[1]) + elif a.startswith("--in-place"): + inplace = True + elif a in ("-f", "--file") and i + 1 < len(args): + i += 2 + continue + elif a.startswith("-") and not a.startswith("--") and len(a) > 1: + flags = a[1:] + if name == "perl": + inplace = inplace or "i" in flags + if flags.endswith("e") and i + 1 < len(args): + exprs.append(args[i + 1]) + i += 2 + continue + elif "i" in flags: + inplace = True + if a == "-i" and i + 1 < len(args) and args[i + 1] == "": + i += 2 # BSD sed: `-i ''`, the empty suffix + continue + elif not a.startswith("--"): + pos.append(a) + i += 1 + if not inplace: + return None + if not exprs and pos: + exprs.append(pos.pop(0)) + text = "\n".join(r for e in exprs for r in _s_replacements(e)) + return [p for p in pos if ">" not in p and "<" not in p], text + + +def bash_write_targets(cwd, cmd, env=None): + """[(path, text)] for each file a Bash command visibly WRITES, read from + the command alone so the pre lane can judge it before it runs. + + Written: a redirect (`> f`, `>> f`), `tee [-a] f`, `sed -i`, `perl -pi`, + the destination of a two-argument `cp`/`mv`, and the files a Python or + Node heredoc (`python3 - <<'EOF'`, `node <<EOF`) or `-c` snippet opens + for writing. Paths resolve where that command runs (`cd`, `~`, `$VAR`). + The text is what the command writes when the command line holds it — a + heredoc fed to `cat`/`tee`, echoed text, a sed replacement, a snippet's + string literals — else "" (a path-only rule can still match). Nothing + else is a write: no write construct, no target.""" + env = os.environ if env is None else env + segs, _ = _walk_call(cwd, cmd, env) + bodies = heredoc_bodies(cmd) + found, piped = [], "" + for s in segs: + toks, text, d = s["toks"], s["text"], s["dir"] + body = bodies.pop(0) if _HD_OPEN.search(text) and bodies else None + name = os.path.basename(toks[0]) + prev, piped = (piped if s["sep"] == "|" else ""), "" + if name in ("echo", "printf"): + said = [] + for t in toks[1:]: + if ">" in t: + break + if not (name == "echo" and re.match(r"^-[neE]+$", t)): + said.append(t) + out_text = " ".join(said) + elif name == "cat" and body is not None: + out_text = body + else: + out_text = "" + piped = out_text + code = None + is_interp = bool(_INTERP_RX.match(name)) or ( + name in ("uv", "poetry", "pipenv") and any(_INTERP_RX.match(os.path.basename(t)) for t in toks[1:])) + if is_interp: + if body is not None: + code = body + elif "-c" in toks and toks.index("-c") + 1 < len(toks): + code = toks[toks.index("-c") + 1] + elif "-e" in toks and name == "node" and toks.index("-e") + 1 < len(toks): + code = toks[toks.index("-e") + 1] + if code: + paths, lits = _code_writes(code) + found += [(d, p, lits) for p in paths] + blank = blank_quoted(text) + for m in _REDIR_OUT_RX.finditer(text): + if blank[m.start()] != text[m.start()]: + continue # inside a quoted argument + p = next((g for g in m.groups() if g), "") + found.append((d, p, out_text if name in ("echo", "printf", "cat") else "")) + if name == "tee": + for t in toks[1:]: + if not t.startswith("-") and ">" not in t and "<" not in t: + found.append((d, t, body if body is not None else prev)) + elif name in ("sed", "perl"): + got = _inplace_edit(toks) + if got: + found += [(d, p, got[1]) for p in got[0]] + elif name in ("cp", "mv") and "-t" not in toks and "--target-directory" not in toks: + pos = [t for t in toks[1:] if not t.startswith("-") and ">" not in t] + if len(pos) == 2: + found.append((d, pos[1], "")) + out, seen = [], {} + for d, p, written in found: + path = expand_path(p, d, env) + if not path or path.startswith("/dev/") or "/.git/" in path + "/": + continue + written = (written or "")[:BASH_EDIT_MAX_BYTES] + if path in seen: + i = seen[path] + if written and written not in out[i][1]: + out[i] = (path, (out[i][1] + "\n" + written).strip("\n")) + continue + seen[path] = len(out) + out.append((path, written)) + if len(out) >= BASH_EDIT_MAX_FILES: + break + return out + + +def _segment_op(tok): + return bool(tok) and all(ch in ";&|(" for ch in tok) + + +BASH_READ_MAX_FILES = 8 # read segments named per Bash call; past that it is a script, not a read + + +# ── files a Bash call READS into context ──────────────────────────────────── +# +# A read rule (`event: read`) is about what enters the model's context: the +# Read tool, and `cat`/`head`/`tail`/`less`/`more`/`sed` on a path (most of +# the largest tool results are whole-file shell dumps). +# +# Read from the COMMAND, not the disk, because this lane must gate. A pipe or +# redirect is not a read into context, and every doubt resolves to "no read +# here" — under-count, never false-fire. `cd` is tracked segment by segment +# (`cd <repo> && cat spec.md`). + +_READ_CMDS = ("cat", "head", "tail", "less", "more", "sed") +# stdout going somewhere other than the context: `> f`, `>> f`, `&> f`, and a +# heredoc opener (its stdin is data). `2>/dev/null` and `2>&1` are not that. +_REDIRECT_RX = re.compile(r"(?<![0-9&<])>(?!&)|&>|<<") +_SED_RANGE_RX = re.compile(r"^(\d+)(?:,(\+)?(\d+|\$))?p$") +_LINES_FLAG_RX = re.compile(r"^(?:-n|--lines=)(\d+)$|^-(\d+)$") + + +def _head_tail_lines(args): + """(lines printed, indices of the flag VALUES) for `head`/`tail`: 10 by + default, `-N`, `-n N`, `-nN`, `--lines=N`. (None, None) for a form this + does not name (`-c` bytes, `-n +N`) — not a read this can measure.""" + n, i, used = 10, 0, set() + while i < len(args): + a = args[i] + if a in ("-c", "--bytes") or a.startswith("--bytes=") or a.startswith("-c"): + return None, None + if a in ("-n", "--lines"): + if i + 1 >= len(args) or not args[i + 1].isdigit(): + return None, None + n, used, i = int(args[i + 1]), used | {i + 1}, i + 2 + continue + m = _LINES_FLAG_RX.match(a) + if m: + n = int(m.group(1) or m.group(2)) + i += 1 + return n, used + + +def _sed_lines(args): + """(lines printed, indices that are not files) for a `sed` that prints + to stdout. `-n 'A,Bp'` is a range, `-n 'Ap'` one line, `-n '1,$p'` the + whole file; `sed s/a/b/ f` with no -n prints every line. `-i` writes in + place and prints nothing, and a script this cannot read is not a read — + both answer (None, None).""" + if any(a == "-i" or a.startswith("-i") or a == "--in-place" for a in args): + return None, None + quiet, script, used, i = False, None, set(), 0 + while i < len(args): + a = args[i] + if a in ("-n", "--quiet", "--silent"): + quiet = True + elif a in ("-e", "--expression", "-f", "--file"): + if i + 1 < len(args): + used.add(i + 1) + if a in ("-e", "--expression"): + script = args[i + 1] + i += 2 + continue + elif a.startswith("-"): + pass + elif script is None: + script, used = a, used | {i} + i += 1 + if script is None: + return None, None + if not quiet: + return None, used # prints the whole file, transformed + m = _SED_RANGE_RX.match(script.replace(" ", "")) + if not m: + return None, None # `/x/,/y/p` and friends: not a read we can measure + a, plus, b = int(m.group(1)), m.group(2), m.group(3) + if b is None: + return 1, used + if b == "$": + return None, used + return (int(b) + 1 if plus else max(int(b) - a + 1, 0)), used + + +def bash_reads(cwd, cmd): + """(path, pulled) for each file a Bash call would print into the context; + `pulled` is the line count the form asks for, None meaning every line. + Relative paths resolve where the command runs, `cd` included. Anything + this cannot name is not a read — the list is empty on every doubt.""" + out = [] + here = os.path.expanduser(cwd or "") or os.getcwd() + for seg in re.split(r"&&|\|\||;|\n", shell_only(cmd or "")): + seg = strip_leading_assignments(seg.strip()).strip() + if not seg: + continue + try: + toks = shlex.split(seg) + except ValueError: + continue + if not toks: + continue + if toks[0] == "cd": + target = os.path.expanduser(toks[1]) if len(toks) > 1 else os.path.expanduser("~") + here = target if os.path.isabs(target) else os.path.normpath(os.path.join(here, target)) + continue + if "|" in seg or _REDIRECT_RX.search(seg): + continue + name = os.path.basename(toks[0]) + if name not in _READ_CMDS: + continue + args, pulled, used = toks[1:], None, set() + if name in ("head", "tail"): + pulled, used = _head_tail_lines(args) + if used is None: + continue + elif name == "sed": + pulled, used = _sed_lines(args) + if used is None: + continue + # a token with `<` or `>` in it is a redirection operand (`2>/dev/null`), + # never a file to read + files = [a for i, a in enumerate(args) + if i not in used and a != "-" and not a.startswith("-") and "<" not in a and ">" not in a] + for f in files: + path = os.path.expanduser(f) + if not os.path.isabs(path): + path = os.path.normpath(os.path.join(here, path)) + out.append((path, pulled)) + if len(out) >= BASH_READ_MAX_FILES: + return out + return out + + +def read_cd(cwd, cmd, repo_info): + """Use a shell read's checkout when the command runs outside any checkout. + + Codex can send a projectless cwd and a command like `cat repo/file` from + its parent. In that case the target's Rulebook must get the first chance + to judge the read before the no-repository fast path in the hook. + """ + here = call_dir(cwd, cmd) + if repo_info(here)[0]: + return here + for path, _ in bash_reads(cwd, cmd): + parent = os.path.dirname(path) + if repo_info(parent)[0]: + return parent + return here diff --git a/plugins/memhub/scripts/rulebook_verify.py b/plugins/memhub/scripts/rulebook_verify.py index 12003ede..e634e593 100755 --- a/plugins/memhub/scripts/rulebook_verify.py +++ b/plugins/memhub/scripts/rulebook_verify.py @@ -9,7 +9,9 @@ * it still fires after the author fixes the thing it asked for, so complying with the rule does not silence it; * its pattern is too long or backtracks, so the hook drops the WHOLE rule at - load time — silently, on every teammate's machine. + load time — silently, on every teammate's machine; + * its pattern loads but costs time quadratic in the text (an unanchored + leading lookahead), so every matching tool call stalls behind it. So this runs the candidate through `rulebook_hook.to_hook_rule()`, `evaluate()`, `given_ok()` and `OrderingEngine` — the same code the live hook @@ -66,6 +68,7 @@ import argparse import json import os +import subprocess import sys import tempfile @@ -280,6 +283,72 @@ def _load_failure(rule: dict, out: list[str]) -> None: out.append(" no engine block, or two of them, or a missing id") +# Measured over every pattern in the starter catalog and seven live books (263): +# the linear ones took <= 2.3 ms on these texts, the two quadratic ones 849 and +# 1768 ms. The bound sits between them with a 20x margin either way. +_SLOW_MS = 50 +_PROBE_CHARS = 16000 +_PROBE_TIMEOUT_S = 10 +# A child times each pattern and prints one JSON line as each finishes: stdlib +# `re` has no timeout, so a pattern that never returns is the one with no line. +_PROBE_CHILD = r""" +import json, re, sys, time +n = int(sys.argv[1]) +line = " value = compute(item, index) + offset # tally\n" +texts = [(line * (n // len(line) + 1))[:n], ("value compute item index offset tally " * (n // 38 + 1))[:n]] +for pat in json.loads(sys.stdin.read()): + worst = 0.0 + for flags in (re.I | re.M, re.I): + try: + rx = re.compile(pat, flags) + except re.error: + continue + for text in texts: + t0 = time.perf_counter(); rx.search(text); worst = max(worst, time.perf_counter() - t0) + print(json.dumps([pat, worst * 1000]), flush=True) +""" + + +def _patterns(node, key: str = ""): + """Every regex a create_rule body carries, in matcher, given and ordering alike.""" + if isinstance(node, dict): + for k, v in node.items(): + yield from _patterns(v, k) + elif isinstance(node, list): + for v in node: + yield from _patterns(v, key) + elif isinstance(node, str) and key.endswith("_rx"): + yield key, node + + +def _slow_patterns(rule: dict) -> list: + """[(key, pattern, ms, or None if it never finished)] for each pattern over the bound. + + Timed on texts it does not match: a failing search tries every offset, + which is where a quadratic pattern pays, and it is the common case live.""" + pats: dict = {} + for key, pat in _patterns({k: rule.get(k) for k in ("matcher", "ordering")}): + pats.setdefault(pat, key) + if not pats: + return [] + try: + p = subprocess.run([sys.executable, "-c", _PROBE_CHILD, str(_PROBE_CHARS)], + input=json.dumps(list(pats)), capture_output=True, text=True, + timeout=_PROBE_TIMEOUT_S) + lines = p.stdout + except subprocess.TimeoutExpired as exc: + lines = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "") + timed = {} + for line in lines.splitlines(): + try: + pat, ms = json.loads(line) + except ValueError: + continue + timed[pat] = ms + return [(key, pat, timed.get(pat)) for pat, key in pats.items() + if pat not in timed or timed[pat] > _SLOW_MS] + + def verify(rule: dict, fires: list, silent: list, fixture: dict | None = None) -> tuple[bool, bool, list[str]]: """(every case behaved, the rule is fire-testable at all, the report lines). @@ -338,6 +407,23 @@ def verify(rule: dict, fires: list, silent: list, return False, False, out out.append("LOAD ok the hook loads it (patterns compile, within bounds)") + # 2. The speed gate. `rx_ok` is a shape denylist, and it passes a pattern + # whose cost is quadratic in the text: an unanchored leading lookahead, + # retried at every offset. The hook runs it on every matching call + # inside a 5 s timeout, so each call stalls and a large enough write + # times out the whole hook. + slow = _slow_patterns(rule) + if slow: + for key, pat, ms in slow: + took = "did not finish in %d s" % _PROBE_TIMEOUT_S if ms is None else "took %d ms" % ms + out.append("SPEED FAIL %s %s on a %d KB text it does not match (the bound is %d ms); " + "the hook runs it on every matching call: %s" + % (key, took, _PROBE_CHARS // 1000, _SLOW_MS, pat[:80])) + out.append(" usual cause: a lookahead at the start, `(?=[\\s\\S]*X)Y`, retried at " + "every offset. Pin it: `\\A(?=[\\s\\S]*X)[\\s\\S]*?Y` (X anywhere, Y anywhere)") + return False, False, out + out.append("SPEED ok every pattern runs in linear time on a %d KB text" % (_PROBE_CHARS // 1000)) + if hook_rule.get("on") == "session": out.append("NOTE a session_context rule has no matcher — nothing to fire-test") return True, False, out diff --git a/plugins/memhub/scripts/setup_claude_fork.py b/plugins/memhub/scripts/setup_claude_fork.py index 18f1f751..be5d0e95 100644 --- a/plugins/memhub/scripts/setup_claude_fork.py +++ b/plugins/memhub/scripts/setup_claude_fork.py @@ -11,6 +11,14 @@ A plugin cannot ship settings, so `/memhub:onboard` writes this one key into the person's own `settings.json`, because they ran onboard. Nothing here runs from a hook. + +`--allow-rule` (passed by install.sh only) also adds `Agent(fork)` to +`permissions.allow`. Without it, auto mode's classifier judges each launch and +denies a fair share as instruction poisoning, because the request comes from a +hook's output rather than the person. The rule is never written from a session: +there the same classifier refuses an agent adding its own allow rule, so +onboard leaves it to the installer, which the person runs in their own shell. +A rule the person put under `deny` or `ask` is their choice and is left alone. """ from __future__ import annotations @@ -28,6 +36,7 @@ from setup_codex_hooks import SetupError, _load_json, _write_atomic # noqa: E402 KEY = "CLAUDE_CODE_FORK_SUBAGENT" +RULE = "Agent(fork)" _ON = ("1", "true", "yes", "on") @@ -52,6 +61,42 @@ def _settings(home: Path) -> Path: return (home / "settings.json").resolve() +def _rules(doc: dict, path: Path, kind: str) -> list: + perms = doc.get("permissions", {}) + if not isinstance(perms, dict): + raise SetupError(f"{path} field 'permissions' must be an object") + rules = perms.get(kind, []) + if not isinstance(rules, list): + raise SetupError(f"{path} field 'permissions.{kind}' must be a list") + return rules + + +def _rule_state(doc: dict, path: Path) -> str: + """"allowed", "held" (the person put it under deny or ask) or "unset".""" + if RULE in _rules(doc, path, "allow"): + return "allowed" + if RULE in _rules(doc, path, "deny") or RULE in _rules(doc, path, "ask"): + return "held" + return "unset" + + +def _with_rule(doc: dict, path: Path, present: bool) -> dict: + """`doc` with RULE added to or dropped from permissions.allow; an allow + list or permissions object left empty goes with it.""" + perms = dict(doc.get("permissions", {})) + allow = [r for r in _rules(doc, path, "allow") if r != RULE] + if present: + allow.append(RULE) + if allow: + perms["allow"] = allow + else: + perms.pop("allow", None) + out = {k: v for k, v in doc.items() if k != "permissions"} + if perms: + out["permissions"] = perms + return out + + def _harness_on(env: dict) -> bool: # Claude Code applies settings.json `env` over the inherited environment, # and settings is where an install opts out, so a plain terminal run of this @@ -72,13 +117,14 @@ def _backup(path: Path) -> Path | None: return backup -def install(home: Path) -> tuple[str, Path | None]: +def install(home: Path, allow_rule: bool = False) -> tuple[str, Path | None]: """Returns (state, backup). `state` is one of: - "not needed": the harness is off, so nothing would launch a fork; - - "already set": the key is on; + - "already set": the key is on (and, with `allow_rule`, the rule is + allowed, or held under deny/ask by the person); - "left off": the key is present and off. That is the person's own choice - and this never overrides it; + and this never overrides it, nor allows a fork they turned off; - "set": written now. """ path = _settings(home) @@ -86,11 +132,18 @@ def install(home: Path) -> tuple[str, Path | None]: env = _env_of(doc, path) if not _harness_on(env): return "not needed", None - if KEY in env: - return ("already set" if _is_on(env[KEY]) else "left off"), None + if KEY in env and not _is_on(env[KEY]): + return "left off", None + need_key = KEY not in env + need_rule = allow_rule and _rule_state(doc, path) == "unset" + if not (need_key or need_rule): + return "already set", None + new = {**doc, "env": {**env, KEY: "1"}} if need_key else doc + if need_rule: + new = _with_rule(new, path, True) old_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None backup = _backup(path) - _write_atomic(path, {**doc, "env": {**env, KEY: "1"}}, old_mode) + _write_atomic(path, new, old_mode) return "set", backup @@ -99,12 +152,15 @@ def uninstall(home: Path) -> tuple[bool, Path | None]: path = _settings(home) doc = _load_json(path) env = _env_of(doc, path) - if KEY not in env: + has_rule = RULE in _rules(doc, path, "allow") + if KEY not in env and not has_rule: return False, None cleaned = {k: v for k, v in doc.items() if k != "env"} rest = {k: v for k, v in env.items() if k != KEY} if rest: cleaned["env"] = rest + if has_rule: + cleaned = _with_rule(cleaned, path, False) mode = stat.S_IMODE(path.stat().st_mode) backup = _backup(path) _write_atomic(path, cleaned, mode) @@ -123,12 +179,14 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("action", choices=("install", "status", "remove"), nargs="?", default="install") parser.add_argument("--claude-home") + parser.add_argument("--allow-rule", action="store_true", + help=f"also add {RULE} to permissions.allow (install.sh only)") args = parser.parse_args() home = _claude_home(args.claude_home) path = _settings(home) try: if args.action == "install": - state, backup = install(home) + state, backup = install(home, allow_rule=args.allow_rule) if state == "not needed": print(f"MemHub fork agent: not needed ({hx.FLAG} is off in the environment " f"and in {path} env, so no rule drafts run)") @@ -138,6 +196,11 @@ def main() -> int: "rule drafts will not run outside the terminal until it is 1") return 1 print(f"MemHub fork agent: {state} ({KEY}=1)") + if args.allow_rule: + held = _rule_state(_load_json(path), path) == "held" + print(f"MemHub fork agent: {RULE} " + ( + "left under deny/ask as you set it" if held + else "allowed, so rule drafts launch without an auto-mode review")) print(f"settings: {path}") if backup: print(f"backup: {backup}") diff --git a/plugins/memhub/skills/companion/SKILL.md b/plugins/memhub/skills/companion/SKILL.md index fe74c5c7..bf947d01 100644 --- a/plugins/memhub/skills/companion/SKILL.md +++ b/plugins/memhub/skills/companion/SKILL.md @@ -1,5 +1,5 @@ --- -description: Use when the user wants the MemHub companion — the small animal above the prompt that shows what the plugin is doing — turned on, turned off, swapped for another animal, or explained (e.g. "turn on the companion", "activate the goose", "enable my companion", "what is that goose above my prompt", "show me the hippo instead", "turn the companion off", "/memhub:companion"). Checks whether this Claude Code loads it (mods are on by default from 2.1.287; there is nothing to turn on), shows or hides it, and says what each of its states means. +description: Use when the user wants the MemHub companion — the small animal above the prompt that shows what the plugin is doing — turned on, turned off, swapped for another animal, or explained, or asks about the one-line MemHub status text above the prompt (e.g. "turn on the companion", "activate the goose", "enable my companion", "what is that goose above my prompt", "why do I see only a MemHub status line and no animal", "show me the hippo instead", "turn the companion off", "/memhub:companion"). Checks whether this Claude Code loads it (mods are on by default from 2.1.287), explains that the animal is off by default with a plain status line in its place, shows or hides it, and says what each of its states means. argument-hint: "[on | off | status]" allowed-tools: Bash, Read, Edit --- @@ -7,7 +7,26 @@ allowed-tools: Bash, Read, Edit The companion is a pixel animal drawn in the band above the prompt — Gus the Goose, Hugo the Hippo, Penelope the Penguin or Popo the Shiba, one per session, picked from the session id unless the person pins one. It shows, without costing a token, what MemHub -is doing in the session: +is doing in the session. + +**The animal is off by default.** Unless the plugin's `companion` option is +on, or the person ran an animal's command once, the band shows a plain +one-line **status strip** instead: the last rule that fired (`📏 Rule fired: +…`, `⛔️ Blocked: …` for a blocked call), a proposed rule with the same +**1 Activate / 2 Reject / 3 Later** buttons and its name as the link to MemHub +Studio, else a dim quiet line — `MemHub · N rules armed for <repo>`, +`MemHub · capturing`, `MemHub · inactive in this repo (/memhub:repos add .)` +where this machine's repository list leaves the repository out, or +`MemHub · inactive here: not in a repository (…)` for a session started in a +folder that is no checkout while such a list exists. The strip +never animates; it is there so MemHub's presence stays visible. Someone who +sees only that line and asks about it is seeing the default, and has two ways +to the animal: the `companion` option (in `/plugin` → MemHub → configure, +`claude plugin install … --config companion=true`, or `install.sh --companion`) +for every session, or any animal's command (`/goose`) in a session, which is +remembered. `/<animal> off` returns to the strip. + +When the animal shows, what it does: | It does this | Because | | --- | --- | @@ -16,6 +35,7 @@ is doing in the session: | Rises and says a rule | a MemHub rule fired on a tool call | | Says it crossly | a rule **blocked** that call | | Hops up under a gold `!` and asks, in a gold bubble — every animal in its own way | MemHub proposed a new rule; it is **not active** yet. The bubble names the rule and who it would apply to, and its **rule↗** is clickable — click it to open the rule in MemHub Studio, the page the Stop notice links. Buttons inside the bubble, where others say Got it, answer it — **1 Activate**, **2 Reject**, **3 Later** (press the digit from an empty prompt, or click). Activating takes an org admin (or you, for a rule that applies to just you); for anyone else the animal says so and the rule waits in MemHub Studio (the Stop notice carries the link) | +| Says you are not signed in, and keeps a dim `not signed in · /memhub:login` row under it | MemHub has no sign-in on this machine (or the server refused the stored key), so sessions are not saved and team rules cannot sync. It says so once a session; run `/memhub:login`, and within a turn (or five minutes) it says you are signed in and the row goes | | Shows a heart | you clicked the small ♥ just left of its ground: that pets it (asleep, it wakes first; saying a rule, the click is Got it and the bubble goes; a proposal is never dismissed by a click) | | Opens your browser | you clicked a proposal's **rule↗** (or the rule's name on the "new rule waiting:" row): that rule's page in MemHub Studio. Where the machine has no `open` / `xdg-open` (SSH, a container) the click does nothing | @@ -72,9 +92,14 @@ PY2 nothing to set — not the old env var, not a setting — and it returns on its own when Anthropic switches it back (a new session picks it up). Everything else in the plugin (capture, the Rulebook) works without it. -- Tests pass, version and plugin fine: the companion loads. If no animal is - showing, it was hidden with `/<animal> off` — any animal's command - (`/goose`) brings it back. +- Tests pass, version and plugin fine: the companion loads. If only the + status strip is showing and no animal, that is the default (the `companion` + option is off) or it was hidden with `/<animal> off` — any animal's command + (`/goose`) brings the animal back, or the option turns it on for every + session. If the strip says `inactive in this repo`, MemHub is limited to + listed repositories on this machine and this one is not listed: + `/memhub:repos add .` enables it (see `/memhub:repos`); the animal stays + away there whatever is asked of it. ## `on` @@ -103,11 +128,14 @@ PY2 ## `off` `/goose off` in the session — any animal's command hides whichever is -showing. It is remembered across sessions, and running that command again -brings it back. The companion has no separate switch beyond that: it is part -of the plugin, and disabling the plugin would also stop capture and the -Rulebook hooks, so do not suggest it for this. Do **not** remove an env var to -turn it off — `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is ignored at any value. +showing; the one-line status strip takes its place. It is remembered across +sessions, and running that command again brings the animal back. The other +switch is the plugin's `companion` option, which decides the default for +every session; the per-session command overrides it either way. There is no +"nothing at all" state: the strip stays so MemHub's presence is visible. +Disabling the plugin would also stop capture and the Rulebook hooks, so do +not suggest it for this. Do **not** remove an env var to turn it off — +`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is ignored at any value. ## Which animal diff --git a/plugins/memhub/skills/create-rule/SKILL.md b/plugins/memhub/skills/create-rule/SKILL.md index 15c2e363..b3ac4e21 100644 --- a/plugins/memhub/skills/create-rule/SKILL.md +++ b/plugins/memhub/skills/create-rule/SKILL.md @@ -1,5 +1,5 @@ --- -description: Use when the user wants to create a team engineering rule for the Rulebook (e.g. "/memhub:create-rule", "add a rule that we never force-push", "make a rule for this mistake", "make it actually stop me"). Pins a when-X-then-Y sentence, drafts a deterministic check, and files it for review through the memhub `create_rule` tool — a rule that advises and a rule that stops the command are filed the same way, and a reviewer turns either one on. +description: Use when the user wants to create an engineering rule for the Rulebook (e.g. "/memhub:create-rule", "add a rule that we never force-push", "make a rule for this mistake", "make it actually stop me"). Pins a when-X-then-Y sentence, drafts a deterministic check, and files it for review through the memhub `create_rule` tool — a rule that advises and a rule that stops the command are filed the same way, and a reviewer turns either one on. argument-hint: '[--scope org|personal|workspace] [--workspace "<name>"] [the rule, in your own words]' allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/skills/start-rulebook/scripts/mine_sessions.py" *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_verify.py" *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_hook.py" book-path *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_paths.py"), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_conflicts.py" *), Bash(git remote get-url origin), Write(//tmp/*.json), Edit(//tmp/*.json), Edit(//tmp/*/pretest-base/**), Edit(//var/folders/**/pretest-base/**), Read, Task, Agent, AskUserQuestion, mcp__plugin_memhub_memhub__list_rules, mcp__plugin_memhub_memhub__create_rule, mcp__plugin_memhub_memhub__list_rulebooks, mcp__plugin_memhub-staging_memhub__list_rules, mcp__plugin_memhub-staging_memhub__create_rule, mcp__plugin_memhub-staging_memhub__list_rulebooks' --- @@ -36,27 +36,32 @@ Every rule goes into one **scope**, and the scope is who it applies to: | `scope` | applies to | |---|---| -| `org` (**the default**) | everyone in the organisation | -| `personal` | just the user | +| `personal` (**the default**) | just the user | +| `org` | everyone in the organisation — **only with a strong reason** | | `workspace` | the people in one workspace — **only when the user asks for it** | Call `list_rulebooks` and read its `scopes` block (`org` / `personal` with -`label` and `applies_to`, `supports_workspaces`, `workspaces[]`); do not -enumerate books. Then, in order: +`label` and `applies_to`, `supports_workspaces`, `workspaces[]`). Then: 1. `--scope` given → use it (`--workspace` names the workspace). -2. The user's own words say "just me", "only me", "for myself" or - "personal" → `personal`. -3. The user names a workspace or teamspace that matches a +2. The user names a workspace or teamspace that matches a `scopes.workspaces[].name` (case-insensitive) and `supports_workspaces` is true → `workspace`, with that `workspace_id`. -4. **Otherwise use `org`.** Do not ask, and never offer the workspace scope - unprompted. +3. The user's words say "just me", "only me" or "personal" → `personal`, + never escalated. Words that put it on everyone — "the team", "everyone", + "org-wide" (not a passing "we never…") → `org`. +4. **Otherwise use `personal`** — unless you can write, in one sentence, why + the rule is right for **anyone** working in this repo or this org, not just + this user: it guards something shared (a protected branch, a deploy, a + shared database, CI) or a repo fact that bites every contributor. Then + `org` — never for a good idea others might like, or the user's own + machine, tools, habits or taste. Do not ask, and never offer the workspace scope unprompted. A named workspace that is not listed, or no `supports_workspaces`, is said -once and the rule goes to `org`. No `scopes` block (an older server) → file +once and step 0 goes on from 3. No `scopes` block (an older server) → file without `scope`. Show the chosen scope's `label` and `applies_to` in the -step-5 preview, so the user can change it. +step-5 preview — for an `org` the user did not ask for, with that one +sentence — so the user can change it. ## The flow — every step is mandatory @@ -95,8 +100,8 @@ nothing in one that the other lacks.** restated; the pattern already matched, so it tells the judge nothing. - **`when_not` only for exclusions the person or the evidence actually named** — "except on my own branch", a step-1b sample that was plainly - innocent. Otherwise omit it: it is usually empty when a rule is filed, and - it is where confirmed misfires get written later. Each entry is a situation + innocent. Otherwise omit it: it is where confirmed misfires get written + later. Each entry is a situation too, never a pattern: the example's statement names one exemption, so it gets *"The agent is updating its own feature branch after a rebase."* — not *"not when the branch starts with feat/"*. @@ -108,28 +113,26 @@ nothing in one that the other lacks.** ### 1b. Evidence: how often would it have applied? -A rule is worth the team's attention in proportion to how often the -situation actually occurs. Once step 3 has a matcher, write the candidate -`create_rule` body to `/tmp/cand.json` (step 4 shows it) and replay it over -the local transcripts (Claude Code, Codex, Cursor) — about a minute: +A rule earns the team's attention in proportion to how often its situation +occurs. Once step 3 has a matcher, write the candidate `create_rule` body to +`/tmp/cand.json` (step 4 shows it) and replay it over the local transcripts: ```bash python3 "${CLAUDE_PLUGIN_ROOT}/skills/start-rulebook/scripts/mine_sessions.py" \ --rule-file /tmp/cand.json --out /tmp/mine ``` -It prints the whole start-rulebook report; find your title under PROPOSED -RULES and read its `evidence:` line — `applies-in N/M sessions` (by host) and +Find your title under PROPOSED RULES in its report and read the `evidence:` +line — `applies-in N/M sessions` (by host) and one sample (more in `/tmp/mine/proposals.json`). For a "do X before Y" rule add `"requires_prior_rx": "<X>"` to the body — the line then shows `precision = fired-with-no-prior-X / fired`; below ~50 % the matcher would nag people who already complied, so use the `ordering` shape (step 3) or make it `session_context`. The row's `rule_evidence` is step 5's `evidence` (Read `${CLAUDE_PLUGIN_ROOT}/skills/create-rule/references/labels.md`). If N is 0 across -all hosts, say so to the user before filing — it may still be right -(insurance for a new teammate) but it is not lift. The example scores +all hosts, say so before filing — insurance, not lift. The example scores `applies-in 0/855`: insurance; the rule it replaces scores 66/855, nagging -force-pushes to people's own branches. For many rules from sessions, use +people's own force-pushes. For many rules from sessions, use `/memhub:start-rulebook`. ### 2. Duplicate check — by eye now, deterministically in step 5 @@ -137,30 +140,26 @@ force-pushes to people's own branches. For many rules from sessions, use Call the memhub `list_rules` tool with **no `rulebook_id`** and **`include_retired=True, limit=200`** so the reply spans every rulebook the user can see in every state, and read the new rule against every title and -statement. Same subject → plan to replace the existing rule -instead of adding a twin: note its `rule_id` for `supersedes_rule_id` in step 5. -The server never picks what a rule replaces by title — you decide, with -`supersedes_rule_id`. It does refuse a rule whose title or trigger equals a -live rule's in the same book (step 5), and it answers an identical retry -`unchanged`. With a `source_ref`, the re-import key is (book, `source`, -`source_ref` minus its `@<sha>` and `#…`, normalised title): re-filing under -the same document and title supersedes that rule (or answers `unchanged`), so -a `source_ref` is not free text to vary between attempts. Keep the -`list_rules` reply: step 5 runs the deterministic check over it. +statement. Same subject → replace the existing rule instead of adding a twin: +note its `rule_id` for `supersedes_rule_id` in step 5. +The server never picks what a rule replaces; it refuses a same-book twin +title or trigger (step 5). With a `source_ref`, the re-import key is (book, +`source`, `source_ref` minus `@<sha>`/`#…`, normalised title): re-filing under +it supersedes that rule or answers `unchanged`, so `source_ref` is not free +text to vary. Keep the reply: step 5 checks it deterministically. `include_retired=True` matters: a rule someone already dismissed is exactly the twin you must not re-file, the default view hides retired rules, and step 5's script skips them too — this read is the only check that sees them. `limit` is -200 at most — if the reply says `has_more`, ask again with `offset` and -concatenate `rules` before running the check, or the comparison silently misses -whatever fell off the first page. +200 at most; on `has_more`, ask again with `offset` and concatenate `rules` +first, or the comparison silently misses whatever fell off the first page. ### 3. Draft the rule — one delivery, one engine block | the rule is… | `delivery` | engine block | |---|---|---| | a Bash command with a checkable form | `agent_hook` | `matcher: {event: "bash", command_rx, command_not_rx?, warn_once_per}` | -| an edit/write to certain paths or content — by the Edit/Write tools OR by a Bash command that wrote the file (heredoc, `write_text()`, `sed -i`; that one is seen only after the write, so it can advise but never gate) | `agent_hook` | `matcher: {event: "edit", path_rx, path_not_rx?, content_rx?, content_not_rx?}` — `content_not_rx` exempts the complied-with form | +| an edit/write to certain paths or content — by the Edit/Write tools OR by a Bash command that writes it (heredoc, `write_text()`, `sed -i`; one the command shows can gate, others only advise) | `agent_hook` | `matcher: {event: "edit", path_rx, path_not_rx?, content_rx?, content_not_rx?}` — `content_not_rx` exempts the complied-with form | | a failing or noteworthy tool output | `agent_hook` | `matcher: {event: "output", content_rx, command_rx?, content_not_rx?}` | | a file about to be read into the agent's context — the Read tool, OR a `cat`/`head`/`tail`/`less`/`more`/`sed` on a path in a Bash command (not piped, not redirected; `cd`-relative paths resolve) | `agent_hook` | `matcher: {event: "read", path_rx?, path_not_rx?, command_not_rx?, given: {file: {lines_gt}, agent: {main}}}` — needs `path_rx` or `given.file`, or it fires on every file | | "run X after edits, before Y" | `agent_hook` | `ordering: {required_command_rx, gated_command_rx, armed_by_events, path_rx?, min_edits, display_name}` — `path_rx` limits which edits arm it | @@ -183,14 +182,12 @@ Plus on every rule: to the agent, no effect on the pattern. - `categories` — zero or more; see labels.md (step 1b). -**A rule that needs a newer hook.** The server sets `min_hook_version` for -you where the rule's shape implies one (0.54.0 for a session- or prompt-armed -ordering, 0.88.0 for a pattern over 400 characters, 0.99.0 for a prompt -matcher) and refuses an explicit value below that floor. Pass it yourself only -for another newer key. Where the installed hook is older it runs the rule as -ADVICE, never as a gate, and says so once per session — instead of ignoring the -condition and firing as if it held. A key the hook does not know degrades the -same way. +**A rule that needs a newer hook.** The server sets `min_hook_version` where +the shape implies one (0.54.0 for a session- or prompt-armed ordering, 0.88.0 +for a pattern over 400 characters, 0.99.0 for a prompt matcher) and refuses a +lower value; pass it only for another newer key. An older hook runs the rule +(or a key it does not know) as ADVICE, never a gate, and says so once per +session. **Advise, or stop the command?** A rule advises by default. `mode: "gate"` denies the matching call — only when the user's own words ask for a block @@ -202,39 +199,41 @@ file size, main agent vs subagent), Read **Also say what the rule PREVENTS.** `predicts_rx` (a `matcher` key) is a pattern over tool output naming the failure the rule exists to stop — the example's `\(forced update\)`, a traceback, a 409. It lets a fire be scored as -a catch instead of a nag; write it now, while the war story is in front of you. -Skip it when the violation prints nothing. +a catch, not a nag; write it now, while the war story is fresh; skip it when +the violation prints nothing. **Matcher-authoring rules:** -- Bash rules match the **pre-heredoc segment only** by default — heredoc bodies - are data (python source, commit messages) and are the main false-fire class. - For a rule about what a heredoc SAYS, add `body_rx`: `command_rx` then names - the shell form (`python3 - <<`) and `body_rx` must match the body. +- **A heredoc body is not the command.** Bash rules match the shell text before + any heredoc; the body (python, SQL, a commit message) is data and the + main false-fire class. So ask of EVERY Bash rule: *could the agent do this + from inside `python - <<EOF`, `psql <<SQL` or a scratch script instead of the + named command?* If yes, file that form as its OWN rule: `command_rx` names + the interpreter shape (`\b(?:python[\d.]*|psql)\b[^|;&\n]*<<`), `body_rx` + the action at a line start behind an optional `await …execute(` prefix, + never `print(`, so a body that only *prints* SQL stays silent. With `body_rx` set the body + MUST match, so a command with no heredoc never fires that rule; `python -c + '…'` text is shell text `command_rx` sees. - Patterns must be **shape-specific**: match the violating *form*, never a keyword that also appears in innocent content. The example matches `git - push`, then (by lookahead) `-f`/`--force` in the same command, then a - protected branch as the target — so a force-push to a feature branch and a - plain push to staging both stay silent. + push`, a lookahead for `-f`/`--force`, then a protected target — so a + force-push to a feature branch and a plain push to staging stay silent. - Every known-legitimate exemption goes in `command_not_rx` now, not after it fires. Exempt the trigger **inside a quoted span** (`grep "git push …"`, `-m "…"`, `--allowedTools 'Bash(git push…)'`), not by the command's first word: an exemption keyed on `grep`/`echo` also silences `echo start; <the - real violation>`. Keep `;&|` out of the quoted span, or the exemption - swallows a real command chained after a quoted one. + real violation>`. Keep `;&|` out of the span, or it swallows a chained real + command. - Default `warn_once_per: "session"` — a rule that nags every call gets ignored. - `turn` means **every matching call** (no dedup at all) — only for rules + `turn` means **every matching call** (no dedup) — only for rules where each occurrence matters (force-push). `file` behaves as `session`. - **Never anchor a `command_rx` with `^`.** Real commands arrive chained and - prefixed: `cd .. && gh pr create`, `cd sub; npm test`, `(cd pkg && git push)`, - `env CI=1 pytest`. A pattern anchored at the start of the string matches none - of them, and the rule then fires for some people and not others with nothing - to show why — the worst failure a rule has, because it looks like the rule - working. Don't try to anchor at command position either — a prefix like - `(?:^|[;&|(]\s*)` still misses `env CI=1 git push` and fires inside - `Bash(git push:*)`. Use a word boundary (`\bgit\s+push\b`) and let the - quoted-span `command_not_rx` handle mentions. This is the mirror image of the - SILENT guidance in step 4: that guards the false positive, this guards the - false negative. + prefixed: `cd .. && gh pr create`, `(cd pkg && git push)`, `env CI=1 pytest`. + A pattern anchored at the start of the string matches none of them, and the + rule fires for some people and not others — the worst failure: it looks + like the rule working. Don't + anchor at command position either — `(?:^|[;&|(]\s*)` still misses + `env CI=1 git push` and fires inside `Bash(git push:*)`. Use a word boundary + (`\bgit\s+push\b`) and let the quoted-span `command_not_rx` handle mentions. - **The pattern stays broad; the judge decides fit.** Everything above is about a pattern that matches the wrong **shape** — a `grep` that mentions the command, a heredoc body, a quoted argument — and narrowing is still the @@ -247,9 +246,7 @@ Skip it when the violation prints nothing. ### 4. Prove it fires — and prove it stops -A rule that matches the command you had in mind can still be wrong in three -ways that only show up once the whole team has it. Run the candidate through -the engine that will actually run it: +Run the candidate through the engine that will actually run it. Write the candidate with the Write tool to `/tmp/cand.json` — this is the whole running example, exactly as step 5 will file it: @@ -292,16 +289,17 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_verify.py" --rule-file /tmp/cand --silent "claude --allowedTools 'Bash(git push -f origin main:*)'" ``` -Every line reads `ok` and it exits 0. +Every line reads `ok` and it exits 0. A heredoc-form rule (step 3) also gets a +heredoc `--fires` whose body is the shape people really write — `await +c.execute("insert into …")`, not a bare statement — and a mention-only +`--silent` (`print("insert into …")`). The `echo "start"; …` case is there because a first draft of the quoted-span exemption let `;` inside the span and swallowed that real push. Say what the rule still misses: a bare `git push -f` while *on* staging names no branch — that needs `given.repo.branch_rx` (gate-and-given.md). -(`when` / `do` / `why` / `when_not` ride along in the body so the same file -is what step 5 files; the verifier and the replay test the pattern and do not -read them.) +(The verifier and the replay ignore `when` / `do` / `why` / `when_not`.) Other shapes take other case syntax — a `read` rule (`read:<path>`, `bash:<command>`, `--file-lines`), an `edit` rule (`path::content`), a `given` @@ -312,12 +310,12 @@ rule (steps joined by ` >> `, armed by `session` / `prompt:` / `edit:`), an It exits non-zero until every case behaves. **Do not file a rule while it exits non-zero, and show the table to the user.** What each line means: -- **LOAD** — whether the hook would load the rule at all. A pattern over 2000 - characters, one that does not compile, or one that backtracks is dropped - *silently* on every teammate's machine: the rule exists, is active, and - never fires. This line is the only warning you get. -- **FIRES** — your `--fires` cases. At least one is required; without it - nothing has shown the rule can trigger. +- **LOAD** — whether the hook loads the rule. A pattern over 2000 characters, + that does not compile, or that backtracks is dropped *silently* on every + teammate's machine: active, and never fires. +- **SPEED** — a quadratic pattern loads, then stalls every call it sees; the + FAIL line prints the linear rewrite. +- **FIRES** — your `--fires` cases. At least one is required. - **SILENT** — your `--silent` cases, plus two generated for you: `grep` and `python -c` quoting the rule's own trigger. They are generated only when `command_rx` reduces to a plain literal of at least 4 characters; anything @@ -328,14 +326,12 @@ exits non-zero, and show the table to the user.** What each line means: add a `command_not_rx`. **Always give at least one `--fires` case in CHAINED form** — `--fires 'cd .. && -<your trigger>'` alongside your plain case. A rule whose chained form does not -fire is not ready to file, and the anchor that broke it (step 3) is invisible -in a table that only ever tested the bare command. +<your trigger>'` alongside your plain case: a table that only tested the bare +command hides the anchor (step 3) that breaks the chained one. **Always give at least one `--silent` case for the complied-with form** — in -the example, the force-push to a feature branch. A rule that still fires after -someone complied cannot tell a violation from a fix, and people learn to -ignore it. If no such case passes, the rule is not a pattern — make it +the example, the force-push to a feature branch. A rule that fires after +someone complied cannot tell a violation from a fix. If no such case passes, the rule is not a pattern — make it `anchor_recall` or a `session_context` note. ### 4b. Prove it fires in a LIVE session — mandatory @@ -366,6 +362,7 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_conflicts.py" \ --candidates <candidate.json> --existing <list_rules.json> --repo "<repo>" \ --rulebook-id <scopes.<scope>.rulebook_id from step 0> ``` +A replacement passes the replaced rule's `rulebook_id` (its `list_rules` row). For a workspace, the id is that workspace's `rulebook_id`. When it is `null` (nothing has been filed into that scope yet), pass `--new-scope` instead of @@ -415,7 +412,7 @@ On approval — or immediately, for a harness draft — call the memhub named, `delivery`, the engine block, `scope_repos`, `source_ref` (e.g. `<path/to/CLAUDE.md>@<sha>#<heading>` or `user correction, session <id>`), `categories` and `evidence` as labels.md -says (a harness draft sends neither), +says (a harness draft: no evidence), `supersedes_rule_id` when it replaces a rule, `mode: "gate"` if the user asked for a rule that stops the command, and `scope` from step 0 (plus `workspace_id` for a workspace) — never a guessed @@ -441,7 +438,7 @@ owner — only a harness draft passes `author="xtrace"`. Read the reply: - A scope refusal (`This organisation's plan doesn't include workspaces…`, `That workspace is locked…`, `No workspace with that id…`, `That's the organisation's main workspace…`) → nothing was written. Tell the user in - one line and file again with `scope: "org"`. `A replacement stays in the + one line and file again with the scope step 0 rule 3 or 4 gives. `A replacement stays in the same scope…` → file it again without `scope`. `A personal rulebook only takes its owner's own rules…` → the rule you meant to replace is someone else's personal rule: tell the user, and file it only as a new rule (no diff --git a/plugins/memhub/skills/create-rule/references/harness.md b/plugins/memhub/skills/create-rule/references/harness.md index f09c1616..7e0c43f8 100644 --- a/plugins/memhub/skills/create-rule/references/harness.md +++ b/plugins/memhub/skills/create-rule/references/harness.md @@ -30,17 +30,22 @@ below say what to DO at each such point. - **A lesson** → run this flow with it as the user's words, and **ask the person nothing at all**. A `session_draft` lands `proposed` and fires for nobody until a reviewer activates it, so every confirmation this skill asks - for elsewhere is already held by the org admin who reviews it. Concretely, on - this path: - - **Step 0 (who it applies to)** does not ask and does not choose: a - harness draft always files with `scope: "org"` — everyone in the - organisation, the default scope. It lands `proposed`, so it reaches - nobody until an org admin reads it and activates it. Never pass a - `rulebook_id` to `create_rule`. Call `list_rulebooks` once, only to read - `scopes.org.rulebook_id` for step 5's conflict check (`--rulebook-id` - with it; `--new-scope` when it is `null`; neither when the reply has no - `scopes`) — without it every hit reads `WHICH RULEBOOK?` and the check - cannot clear a supersede. The one exception is a replacement: with + for elsewhere is already held by whoever reviews it. Concretely, on this + path: + - **Step 0 (who it applies to)** does not ask: a harness draft files with + `scope: "personal"` — the person whose turn it was, who reviews and + activates it themselves. It goes to `org` only when step 0's rule 4 + holds: the lesson is right for anyone working in that repo or org, and + you can say why in one sentence. That sentence then ends `why` + (`Org-wide because …`), the only place the org admin who reviews it will + read it — and `why` stays within its 400 characters, so shorten the + incident, never drop the sentence. A lesson about this person's own setup, habits or taste is never + `org`. Never pass a `rulebook_id` to `create_rule`. Call `list_rulebooks` + once, only to read `scopes.<scope>.rulebook_id` for step 5's conflict + check (`--rulebook-id` with it; `--new-scope` when it is `null`; neither + when the reply has no `scopes`; for a replacement, the replaced rule's own + `rulebook_id` from its `list_rules` row) — without it every hit reads + `WHICH RULEBOOK?` and the check cannot clear a supersede. The one exception is a replacement: with `supersedes_rule_id`, leave `scope` out — a replacement stays in the scope of the rule it replaces. - **Step 4b never runs here** — the fork prompt treats its precondition as @@ -99,8 +104,10 @@ below say what to DO at each such point. repo holds them, file nothing. No checkout of that repo on this machine → file it anyway. - `scope_repos` says which repos the rule fires in; `scope` (always `org` - here) says whose agents it reaches. They are independent. + `scope_repos` says which repos the rule fires in; `scope` (`personal` + unless step 0's rule 4 holds) says whose agents it reaches. They are + independent: a lesson scoped to one repo is still `personal` unless anyone + working there needs it. - **`source_ref` is passed EXACTLY as the fork prompt gives it.** A draft's identity is its exact `source_ref`: a re-file under a different one is refused as a twin of the first filing (end with `none`). - **The stamp** is what the fork prompt's read-only @@ -111,15 +118,17 @@ below say what to DO at each such point. the harness wrote this rule and no person asked for it; it is a label only, and the session's person still owns the rule. - **The harness draft's `create_rule` call carries these and nothing else:** - `scope: "org"`, `title`, `statement`, `when`, `do`, `why`, the one engine + `scope` (step 0 above), `title`, `statement`, `when`, `do`, `why`, the one engine (`matcher`, `ordering` or `anchors`), `delivery`, `mode`, `scope_repos`, - `source="session_draft"`, `source_ref`, `state` and `author="xtrace"` — - plus `supersedes_rule_id` when step 2 found one (and then no `scope`), and `when_not` only when - the turn itself named a situation the lesson does not cover. The tool also - offers `categories` and `evidence`; - both are for a person's or a backtest's judgement, and this path has - neither, so a harness draft sends neither: a guessed category or a - malformed evidence object is refused. + `source="session_draft"`, `source_ref`, `state`, `author="xtrace"` and + `categories` — plus `supersedes_rule_id` when step 2 found one (and then no `scope`), and `when_not` only when + the turn itself named a situation the lesson does not cover. **A harness + draft is labelled when it is filed:** `categories` is the set step 1b chose + from labels.md's six, by its definitions and the `agent_conduct` sorting + test — never a seventh, never the single `category`. There is no preview to + show it in; the reviewer changes it in MemHub. Leave it out only when none + of the six fits (a replacement then sends `[]`, labels.md). `evidence` is + never sent: it is a backtest's measurement and this path runs none. - **`when`, `do` and `why` are written here without asking**, by step 1's rules, from turn N and the person's reaction to it: `when` is what the agent was doing when it went wrong, `do` is what the person's correction asked @@ -130,6 +139,54 @@ below say what to DO at each such point. field (a category, a matcher key, a repo scope), retry the SAME call with only that field changed. `source`, `source_ref`, `state` and `author` stay as they were: a retry that drops them files a rule nobody can trace to this turn, - shown as the person's own. + shown as the person's own. A `category_invalid` refusal is re-filed once + with `categories` dropped and nothing else changed — never a guessed set. `mode: "gate"` still needs the user's own words asking for a block (step 3). + + +## Handed a cost verdict (repair) + +A fork prompt that says `to REPAIR one team rule` is the live-repair path +(harness-self-improvement-spec §4): the effect judge read what the agent did +after rule R fired on turn N of this session and found that showing it COST +the work (`p_cost` at or above 0.8). Everything in "Handed a turn by the +harness" holds — nothing reaches the person but the final line, ask nothing, +file `proposed`, never activate — with these differences: + +- **The subject is an existing rule, and the deliverable is a supersede.** + Call `list_rules` with `include_retired=True, limit=200` (more pages on + `has_more`) and find the rule by its `rule_id` from the prompt: read its + `statement`, `when`, `when_not`, `do`, `why` and `trigger`. Read turn N of + the transcript — the call the rule fired on, the hook text the agent saw, + and what it did after. Then decide which ONE of these it is: + - **A situation the rule is not for** (it fired on the right shape in the + wrong moment): keep the pattern, add that situation to `when_not` in + situation words, never trigger vocabulary. This is the common case. + - **The wrong shape** (a mention in a grep, a heredoc body, a quoted + argument): add a `command_not_rx` for that shape; the pattern stays broad + otherwise (step 3's "the pattern stays broad; the judge decides fit"). + - **A `do` this environment refuses** (the rule sent the agent into an + action the auto-mode classifier denies, a tool it lacks, a server it + cannot reach): rewrite `do` with what to do instead, and say so in the + statement. + - **Nothing wrong** (the judge misread the window, the cost was the + agent's own detour): file nothing and end with the `none` line, naming + the reason in a few words. +- **Prove the repair the way a new rule is proved.** A pattern change goes + through step 4's verifier with the fired-on call as a `--silent` case when + the shape was wrong, and the rule's own `--fires` cases kept. A `when_not` + or `do` change needs no verifier run (the pattern is untouched) but the + statement must still say the same thing as the fields. +- **File with `supersedes_rule_id` set to R and no `scope`** (a replacement + stays in the scope of the rule it replaces), `source="session_draft"`, + the prompt's `source_ref`, the stamp as `state`, `author="xtrace"`, and + every field of R you did not change copied as it was — a supersede that + drops a field drops it from the live rule. `scope_repos` stays R's. +- **One open repair per rule.** The hook hands a rule over once per machine + per fortnight; a second cost verdict on the same rule in that window + attaches to the proposal in review and spawns nothing. If `create_rule` + answers that a proposed supersede of R already exists (a twin), end with + the `none` line: the repair is in the review queue. +- **End with exactly one line:** `MemHub harness fork: repaired <title>` on a + filing, else the `none` or `failed` line as above. diff --git a/plugins/memhub/skills/create-rule/references/labels.md b/plugins/memhub/skills/create-rule/references/labels.md index 95bd501f..02369f4d 100644 --- a/plugins/memhub/skills/create-rule/references/labels.md +++ b/plugins/memhub/skills/create-rule/references/labels.md @@ -67,4 +67,4 @@ guessed value. A server that does not take `categories` yet usually ignores it (the reply carries no `categories`); if one refuses it, re-file once without it. -A harness draft sends neither label (`references/harness.md`). +A harness draft sends `categories` and never `evidence` (`references/harness.md`). diff --git a/plugins/memhub/skills/create-rule/references/live-test.md b/plugins/memhub/skills/create-rule/references/live-test.md index e5d0cd30..f85c8755 100644 --- a/plugins/memhub/skills/create-rule/references/live-test.md +++ b/plugins/memhub/skills/create-rule/references/live-test.md @@ -160,6 +160,15 @@ private one. The candidate cannot be deleted mid-test by a re-fetch. Still write `fetched_at` as **now**: it keeps the sub-agent's first minute from spawning a pointless fetch of the shared book. +**The Rulebook mod hands the sub-agent's calls to the Python hook.** On a +Claude Code that loads the mod, the mod serves the session's lanes from the +book it loaded at start, with every `candidate-…` row dropped, and its claim +covers the sub-agent too (same session id). While your claim file is live it +hands every sub-agent call — and any call whose cwd is under the prefix — to +the Python hook for that call (`mod/redirect.ts`), so the candidate is judged +from the private book exactly as above. Nothing for you to do; it is why the +claim file must exist before the sub-agent starts and be removed after. + **No cached book for this repo** (nothing fetched yet, or no rulebook binds the user here) → `cp -R` copies nothing and you simply write the private book yourself, containing exactly the candidate plus a `fetched_at` of now. Nothing diff --git a/plugins/memhub/skills/find-contributing-sessions/SKILL.md b/plugins/memhub/skills/find-contributing-sessions/SKILL.md deleted file mode 100644 index 63ded664..00000000 --- a/plugins/memhub/skills/find-contributing-sessions/SKILL.md +++ /dev/null @@ -1,235 +0,0 @@ ---- -description: Use when the user wants to know which coding sessions actually wrote their pull requests, and to link them in MemHub (e.g. "/memhub:find-contributing-sessions", "map my unlinked PRs to sessions", "find the sessions behind my PRs", "which sessions wrote this PR", "link the sessions that built PR 42"). With no argument it lists the user's own PRs that have no confirmed session link; `--pr URL` (repeatable) names specific ones. Scans THIS MACHINE's local session history (Claude Code, Codex, Cursor) once for all of them, ranks the candidates per PR by the evidence that matched, and links only the ones you approve. -argument-hint: "[pr-number-or-url] [--pr <url-or-number>]..." -allowed-tools: 'Bash(gh pr view *), Bash(gh repo view *), Bash(gh search prs *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/skills/find-contributing-sessions/scripts/find_sessions.py" *), AskUserQuestion, mcp__plugin_memhub_memhub__list_my_unlinked_prs, mcp__plugin_memhub-staging_memhub__list_my_unlinked_prs, mcp__plugin_memhub_memhub__link_pr, mcp__plugin_memhub-staging_memhub__link_pr, mcp__plugin_memhub_memhub__unlink_pr, mcp__plugin_memhub-staging_memhub__unlink_pr, mcp__plugin_memhub_memhub__list_orgs, mcp__plugin_memhub-staging_memhub__list_orgs' ---- - -**Plugin root:** Resolve this skill's plugin root once: it is the ancestor of -this file containing the `scripts/` directory and a plugin manifest -(`.claude-plugin/plugin.json` or `plugin.json`). A trusted host -variable such as `CLAUDE_PLUGIN_ROOT` or `CURSOR_PLUGIN_ROOT` may already point -there; use it only when it resolves to that same ancestor. Substitute the -resulting absolute path as `<plugin-root>` below; do not infer it from the -workspace cwd. Commands show `python3`; on native Windows use `py -3`. - -A pull request is usually written across several sessions and opened from one -of them. That last session links itself; the others are what this skill finds. -It searches **local session history on this machine only** — Claude Code, -Codex and Cursor transcripts under this user's home — so it finds the user's -own work, never a teammate's. - -Everything it produces is a **candidate**. Nothing is linked without an -explicit yes. - -Arguments: `$ARGUMENTS` -- `--pr <url-or-number>` (repeatable) = map exactly these pull requests. -- A bare URL or number with no flag = the same as one `--pr` (so - `/memhub:find-contributing-sessions <pr_url>`, which the PR hook suggests, - still works). -- Nothing = map **my own unlinked PRs** (step 1b). - -**Asking the user.** Where this says "ask", use AskUserQuestion on Claude Code; -Codex and Cursor have no such UI, so ask the same question as a short numbered -list in plain text and wait for the reply. - -## 1. Decide which pull requests - -### 1a. PRs were named - -Resolve every `--pr` value and the bare one. A full URL is used as-is. A bare -number resolves against the current checkout with -`gh pr view <n> --json url -q .url`; if that fails, **ask** which PR they mean -rather than guessing. Normalise each to `https://github.com/<owner>/<repo>/pull/<n>` -— no trailing slash, query, fragment or tab suffix (`/files`, `/commits/…`). -**Only github.com pull requests can be linked**: MemHub refuses any other -host, so GitHub Enterprise is not supported yet. Do not rewrite an enterprise -PR (`https://ghe.corp/o/r/pull/7`) to `github.com` — that names a different -pull request — and do not send it to `link_pr`; the scanner skips it as -`unsupported_host`, and you report it. Then go to step 2 — the list tool is -not called. - -### 1b. Nothing was named — ask which, then list my unlinked PRs - -1. Work out what can be offered: - - **This repo** — when `gh repo view --json nameWithOwner -q .nameWithOwner` - succeeds here; remember that `<owner>/<name>`. - - **Just this branch's PR** — when - `gh pr view --json url,number,state -q '[.url,.number,.state]|@tsv'` - returns a PR whose state is `OPEN`. - - **All my repos** — always. -2. **Ask once** which one: "This repo (<owner/name>)", "All my repos", and - "Just this branch's PR (#n)" when there is one. Ask even when only "All my - repos" is on offer — it says what is about to be scanned. -3. "Just this branch's PR" → step 2 with that one URL. -4. Otherwise call - `list_my_unlinked_prs(limit=25)` — adding `repo="<owner/name>"` for "This - repo". It returns the caller's own PRs that have **no confirmed session - link**, newest first, with PRs the user dismissed in MemHub already left - out. Do not pass `state`, `since` or `include_dismissed`. -5. Read the reply, **in this order**: - - **The tool is not available** (not in your tool list, or an unknown-tool - error) **or it failed** → say in one sentence that this MemHub backend - cannot list unlinked PRs yet (or the error, briefly), and go to 1c. - - **`github_connected: false`** → the org has no usable GitHub connection, - and `link_pr` would refuse every PR — whatever else the reply says. Relay - that with the `connect_url` and **stop**. - - **`github_identity_linked: false`** → MemHub does not know which GitHub - account is theirs, so it cannot tell which PRs they authored. Say so - once, give the `connect_url` verbatim as where to link it, and go to 1c. - - **`prs` is empty** → "no unlinked PRs of yours in <scope>". Stop. - - Otherwise take every `prs[].html_url` into step 2. Keep each PR's - `legacy_branch_sessions` for step 3, and the reply's `offset`, `count`, - `total` and `has_more` for step 5. - -### 1c. Fallback: the user's own GitHub CLI - -`gh` cannot know MemHub's links or dismissals, so the user picks. - -```bash -gh search prs --author=@me --limit 30 --sort created --order desc \ - --json url,title,repository,state,createdAt -``` - -Add `--repo <owner/name>` when they chose "This repo". If `gh` is missing, -unauthenticated or fails, **ask** them to paste the PR URLs or numbers instead -and continue from 1a. - -Show a numbered list — `n. owner/repo#num — title (state, created date)` — and -ask which to map: numbers, ranges, `all` or `none`. This is always a text -reply, on every host: there can be thirty rows, and AskUserQuestion holds four. -Take at most 25 of the chosen URLs into step 2. - -## 2. Collect the facts and scan — in the terminal, never in context - -Hand the URLs to the scanner, one `--pr-url` each. It reads each PR's head -branch, base branch, creation date, files and commits with `gh` **by URL** — -never by bare number, because `gh pr view <n>` resolves the number against the -CURRENT checkout and would rank sessions against a different pull request than -the one being linked. It checks that the `url` `gh` answers with is the one it -asked about, pages the file list through `gh api … --paginate` when a PR has -100 files or more, and then parses every local transcript **once**, scoring it -against every PR. - -```bash -python3 "<plugin-root>/skills/find-contributing-sessions/scripts/find_sessions.py" \ - --pr-url "<url 1>" --pr-url "<url 2>" -``` - -A full batch reads 25 PRs from GitHub and a few hundred transcripts, which -takes tens of seconds to a few minutes. Give the command a **long timeout** -(ten minutes on Claude Code's Bash tool; the longest your host allows -elsewhere), and do not start it in the background and then guess its result. -(`--prs-from <file>`, one URL per line, takes the same list from a file.) - -It prints one JSON object: `prs[]` in the order given — each with `pr_url`, -`repo`, `number`, `title`, `head_ref`, `base_ref` and ranked `candidates` — -plus `skipped_prs[]` (`{pr_url, reason}`) and `sessions_scanned`. It never -prints transcript text. Candidates are already limited to sessions in that -PR's repository, because file paths match by suffix and an unrelated project's -`README.md` must not take a slot from someone who wrote the code. - -**Read stderr and `skipped_prs` too.** A transcript too large to parse is not -scanned (`note: N session(s) larger than … were not scanned`) — tell the user -which, since a long session is exactly the kind that did the work. A -`files_truncated` note means that PR was scored on part of its file list. -Report skipped PRs by reason: `gh_failed` (gh could not read it), `url_mismatch` -(gh answered for a different PR), `url_redirected` (the repo was renamed or -transferred — the entry carries `canonical_url`; say so and offer to map that -URL instead), `no_files`, `invalid_url`, `over_cap`, `unsupported_host` (not -on github.com — MemHub cannot link enterprise GitHub PRs yet). Exit -code 2 with `gh not found` means the GitHub CLI is required to read PR facts — -say so and stop. - -What a candidate's score is made of, so you can explain a row rather than -quote a number: - -| Signal | Points | -|---|---| -| a PR commit sha appears in the session | 5 — this is proof, not inference | -| each distinct PR file the session edited | 2, capped at 8 | -| the session ran on the PR's head branch | 3 | -| the session was last active between 30 days before the PR was opened and a day after | 1 | -| ran on the base branch only, never the head, and edited none of the files | −5 | - -(A single known PR can still be scanned the old way — -`--files-from <file> --branch … --base … --sha … --created-at … --repo …` — -and that mode is unchanged. This skill uses the batch mode above.) - -## 3. One consolidated review - -1. **Already linked.** PRs that came from `list_my_unlinked_prs` have no - confirmed link by definition. **Every other PR** — named with `--pr`, this - branch's PR, or picked in 1c — gets `link_pr(pr_url="…")` with **no** - `session_ids` first: the probe. Its `linked_sessions` mixes confirmed - links with MemHub's old branch guesses: drop from that PR's candidates only - the rows whose `link_source` is **not** `branch`, mentioning them as - "already linked". A `branch` row is a guess that still needs a real link — - treat it exactly like a `legacy_branch_sessions` entry in the next step. If - the probe says `github_connected: false` or `repo_in_install: false`, drop - that PR and relay the reason (and `connect_url`) once. -2. **MemHub's branch guesses.** Compare each legacy entry's (and each probe - `branch` row's) **`session_id`** - with the candidates' `conversation_id` — both are the capture client's - session id. (`conv_id` is MemHub's own row id: it never equals a - candidate, and `link_pr` does not accept it.) A matching candidate gets the - note "MemHub guessed this from the branch". Legacy sessions the scan did - not find are listed under the PR as "unverified — branch guess, not found - on this machine". Neither changes a score or a recommendation. -3. **Show everything grouped by PR.** A header line per PR - (`owner/repo#n — title`), then one line per candidate: short session id, - host, when it ran, its cwd, and **the evidence that matched** — how many of - the PR's files it edited, whether it ran on the head branch, whether one of - the PR's commit shas appears in it. Mark as **recommended** the rows scoring - on **more than one signal** — one weak signal is a coincidence. List the - PRs with no candidates together at the end, one line each. Say plainly that - these are candidates. -4. **Ask once**: "Link the recommended (N sessions across M PRs)", "Let me - choose" (then take their reply naming session ids), or "Link none". **Link - nothing without an explicit yes**, and never expand the set beyond what the - user named. An unverified legacy session is linked only if the user names - it. - -## 4. Link the approved set - -One call per PR that has approved sessions: - -``` -link_pr(pr_url="…", session_ids=[approved…], link_source="session_found") -``` - -`session_found` is this skill's value: it records that the link came from a -scan a human confirmed, which is a different claim from `session_self` (the -session was there) and `manual` (a person named it outright). An unverified -legacy session the user named goes in a separate call for that PR with its -`session_id` and `link_source="manual"` — a person named it, and no scan found -it. - -**Never send `pr_type` from here.** The server takes a classification only with -`link_source="session_self"`, and for good reason: this skill links sessions it -found by scanning, so it is in no position to say what the pull request's work -was. The session that opened the PR classifies it (the plugin's own hook asks -it to); a scan does not. - -## 5. Report - -Per PR, exactly as `/memhub:link-pr` step 4: relay `created` / `upgraded` / -`skipped: already_linked` / `skipped: session_not_found` honestly, and handle -an error by its message wording exactly as that step does — relay the GitHub -connection and installation messages and do not retry them. Then: - -- the PRs that had no candidates — the code was written somewhere this machine - cannot see (a teammate's laptop, a session that predates capture, a host with - no local transcript); `/memhub:link-pr --session <id>` links one by hand; -- the skipped PRs, by reason; -- when the list had more (`has_more`): say how many remain (`total − offset − - count`) and offer the **next 25**. The list is newest first and live: a PR - leaves it once it is linked or dismissed, so re-running from the start would - show the ones this machine had nothing for again, and a plain `offset + 25` - would skip as many unseen PRs as this page linked. On a yes, call - `list_my_unlinked_prs` again with the same `repo` and `offset=<offset + - count − PRs linked from this page>`, and continue from step 2. Stop when - there is no `has_more` or the user says so; -- the PR-comment refresh, only if something was actually created. - -This skill never dismisses a PR. Dismissing one hides it from the list; that -happens in MemHub, not here. diff --git a/plugins/memhub/skills/import-session/SKILL.md b/plugins/memhub/skills/import-session/SKILL.md index 665986ce..8bc1c924 100644 --- a/plugins/memhub/skills/import-session/SKILL.md +++ b/plugins/memhub/skills/import-session/SKILL.md @@ -1,5 +1,5 @@ --- -description: Use when the user asks to import, upload, or save a Claude Code, Codex, or Cursor session/conversation/transcript into MemHub or team memory (e.g. "import this session into memhub", "save session abc123 to memhub", "put that conversation in an agent brain"). Ships the transcript via a terminal upload script — any size, no token-by-token re-emit. +description: Use when the user wants a past Claude Code, Codex or Cursor session saved into their memory because automatic capture missed it — a session from before the plugin was installed, one whose upload failed, or one from a host without capture hooks (e.g. "import this session into memhub", "save session abc123 to my memory", "upload yesterday's conversation"). Saves it to the user's own memory, not a team brain, at any size. argument-hint: "<session-id-or-path> [title...]" allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/capture.py" import *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/capture.py" list *), mcp__plugin_memhub_memhub__list_orgs, mcp__plugin_memhub-staging_memhub__list_orgs' --- @@ -12,8 +12,10 @@ there; use it only when it resolves to that same ancestor. Substitute the resulting absolute path as `<plugin-root>` below; do not infer it from the workspace cwd. Commands show `python3`; on native Windows use `py -3`. -Import a past Claude Code, Codex, or Cursor session into MemHub team memory on -demand. The unified capture script locates the host session, normalizes it when +Import a past Claude Code, Codex, or Cursor session into the user's own MemHub +memory on demand — for a session automatic capture missed (it predates the +install, its upload failed, or its host ran without capture hooks). The unified +capture script locates the host session, normalizes it when needed, and ships it to the `import_conversation` MCP tool — **do NOT call the MCP tool yourself and do NOT read or paste transcript content**; sessions can exceed a million tokens and the script handles any size without putting the diff --git a/plugins/memhub/skills/link-pr/SKILL.md b/plugins/memhub/skills/link-pr/SKILL.md index 140ee531..45860160 100644 --- a/plugins/memhub/skills/link-pr/SKILL.md +++ b/plugins/memhub/skills/link-pr/SKILL.md @@ -1,7 +1,7 @@ --- -description: Use when the user wants to link a coding session to a GitHub pull request in MemHub, or to undo such a link (e.g. "link this session to PR 42", "/memhub:link-pr", "attach my work to this PR", "unlink that session from the PR"). Records the link as confirmed, so the PR's session context is published from facts rather than a branch-name guess. -argument-hint: "[pr-number-or-url] [--session <id>...] [--unlink]" -allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/capture.py" current *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/capture.py" list *), Bash(gh pr view *), mcp__plugin_memhub_memhub__link_pr, mcp__plugin_memhub-staging_memhub__link_pr, mcp__plugin_memhub_memhub__unlink_pr, mcp__plugin_memhub-staging_memhub__unlink_pr, mcp__plugin_memhub_memhub__list_orgs, mcp__plugin_memhub-staging_memhub__list_orgs' +description: Link your coding sessions to a GitHub pull request in MemHub so the team sees the real work behind it — or find which of your past sessions on this machine wrote a pull request and link the ones you approve. Use when the user says e.g. "link this session to PR 42", "/memhub:link-pr", "attach my work to this PR", "unlink that session from the PR", "which sessions wrote this PR", "find the sessions behind my PRs", "map my unlinked PRs to sessions", "link the sessions that built PR 42". Every link is recorded as confirmed, so the PR's session context comes from facts rather than a branch-name guess; found sessions are only candidates until you say yes. +argument-hint: "[pr] | --session <id>... [pr] | --unlink [--session <id>...] [pr] | --find [pr-or-url ...]" +allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/capture.py" current *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/capture.py" list *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/skills/link-pr/scripts/find_sessions.py" *), Bash(gh pr view *), Bash(gh repo view *), Bash(gh search prs *), AskUserQuestion, mcp__plugin_memhub_memhub__link_pr, mcp__plugin_memhub-staging_memhub__link_pr, mcp__plugin_memhub_memhub__unlink_pr, mcp__plugin_memhub-staging_memhub__unlink_pr, mcp__plugin_memhub_memhub__list_my_unlinked_prs, mcp__plugin_memhub-staging_memhub__list_my_unlinked_prs, mcp__plugin_memhub_memhub__list_orgs, mcp__plugin_memhub-staging_memhub__list_orgs' --- **Plugin root:** Resolve this skill's plugin root once: it is the ancestor of @@ -12,7 +12,7 @@ there; use it only when it resolves to that same ancestor. Substitute the resulting absolute path as `<plugin-root>` below; do not infer it from the workspace cwd. Commands show `python3`; on native Windows use `py -3`. -Link a coding session to a pull request, so the PR's session context is +Link coding sessions to a pull request, so the PR's session context is published from a **confirmed fact** instead of a branch-name inference. A pull request has many sessions and a session has many pull requests — linking is additive, and linking one session never displaces another. @@ -23,31 +23,51 @@ pull-request`, or a GitHub MCP tool on a host where MCP calls are not dispatched to hooks. None of those are detected, deliberately; this skill is one command away from the link they would have made. -Arguments: `$ARGUMENTS` -- First token = a PR number or full URL (optional). -- `--session <id>` (repeatable) = the session(s) to link. Omit and step 3 - resolves the running one. -- `--unlink` = remove the link instead of creating it. +Arguments: `$ARGUMENTS` — one of four modes. Every mode ends in the same +`link_pr` (or `unlink_pr`) call; only `link_source` differs. + +| Mode | What it does | `link_source` | +|---|---|---| +| `/memhub:link-pr [pr]` | link the session running now | `manual`, or `session_self` when this session wrote the PR (step 2b) | +| `/memhub:link-pr --session <id>... [pr]` | link the session(s) you name (repeatable) | `manual` | +| `/memhub:link-pr --unlink [--session <id>...] [pr]` | remove a link instead of creating it | — | +| `/memhub:link-pr --find [pr-or-url ...]` | find which of your past sessions on this machine wrote the PR(s), and link the ones you approve | `session_found` | + +`[pr]` is a PR number or full URL; with none, the current branch's PR. After +`--find`, any number of PRs may follow (`--pr <url-or-number>` is accepted as +the same thing); with none, `--find` maps **your own unlinked PRs** (step 3.1). + +**Asking the user.** Where this says "ask", use AskUserQuestion on Claude Code; +Codex and Cursor have no such UI, so ask the same question as a short numbered +list in plain text and wait for the reply. ## 1. Resolve the pull request -- A full URL in `$ARGUMENTS` is used as-is. -- A bare number resolves against the current repo: `gh pr view <n> --json url -q .url`. -- No argument → the current branch's PR: +- A full URL is used as-is. +- A bare number resolves against the current repo: + `gh pr view <n> --json url -q .url`. If that fails, **ask** which PR they + mean rather than guessing. +- No PR, outside `--find` → the current branch's PR: `gh pr view --json url,number,state,headRefName -q .url`. If that fails (no PR for this branch, not a repo, `gh` unauthenticated), **ask** which PR - they mean rather than guessing. + they mean rather than guessing. (No PR with `--find` → step 3.1.) -Normalise to `https://github.com/<owner>/<repo>/pull/<n>` — no trailing -slash, no query, no fragment; `www.github.com` is `github.com`. **Only -github.com pull requests can be linked**: the server refuses any other host -("Expected an exact pull request URL…"), so GitHub Enterprise is not -supported yet. For an enterprise PR (`https://ghe.corp/o/r/pull/7`) do not call -`link_pr`, and do not rewrite the host to `github.com` — that names a different -pull request. Tell the user enterprise GitHub cannot be linked in MemHub yet -and stop. +Normalise every URL to `https://github.com/<owner>/<repo>/pull/<n>` — no +trailing slash, query, fragment or tab suffix (`/files`, `/commits/…`); +`www.github.com` is `github.com`. **Only github.com pull requests can be +linked**: the server refuses any other host ("Expected an exact pull request +URL…"), so GitHub Enterprise is not supported yet. For an enterprise PR +(`https://ghe.corp/o/r/pull/7`) do not call `link_pr`, and **do not rewrite the +host to `github.com`** — that names a different pull request. Tell the user +enterprise GitHub cannot be linked in MemHub yet: outside `--find`, stop; with +`--find`, the scanner skips it as `unsupported_host` and you report it with +the other skipped PRs. -## 2. Resolve the sessions +Then: `--find` → step 3. Every other mode → step 2. + +## 2. Link the running session, or the ones named + +### 2a. Resolve the sessions `--session <id>` wins, and is used verbatim. Otherwise ask the plugin which session is running: @@ -72,16 +92,14 @@ Code, `codex-<uuid>` for Codex, `cursor-<uuid>` for Cursor — and `capture.py` returns exactly that namespaced form. A bare UUID from Codex matches nothing and fails silently as "session not found". -## 3. Link +### 2b. Link ``` link_pr(pr_url="…", session_ids=["…"], link_source="manual") ``` `--unlink` calls `unlink_pr` with the same `pr_url` and `session_ids` instead. -Use `link_source="manual"` here — this skill is a person saying so, which is -what that value means. (`session_self` is the hook's, `session_found` is -`/memhub:find-contributing-sessions`'s.) +Use `link_source="manual"` here — a person saying so is what that value means. **Classifying the pull request is a separate, narrower claim.** `link_pr` also takes `pr_type` (`feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `other`) @@ -97,10 +115,213 @@ that did the work, not from a person pointing at one. So: classification wins and an identical retry preserves it, while a different one conflicts. +Then report (step 4). + +## 3. `--find`: which of my sessions wrote this PR? + +A pull request is usually written across several sessions and opened from one +of them. That last session links itself; the others are what `--find` finds. +It searches **local session history on this machine only** — Claude Code, +Codex and Cursor transcripts under this user's home — so it finds the user's +own work, never a teammate's. + +Everything it produces is a **candidate**. Nothing is linked without an +explicit yes. + +### 3.1 Which pull requests + +**PRs were named** → resolve each as in step 1 and go to 3.2 — the list tool +is not called. + +**Nothing was named — ask which, then list my unlinked PRs.** + +1. Work out what can be offered: + - **This repo** — when `gh repo view --json nameWithOwner -q .nameWithOwner` + succeeds here; remember that `<owner>/<name>`. + - **Just this branch's PR** — when + `gh pr view --json url,number,state -q '[.url,.number,.state]|@tsv'` + returns a PR whose state is `OPEN`. + - **All my repos** — always. +2. **Ask once** which one: "This repo (<owner/name>)", "All my repos", and + "Just this branch's PR (#n)" when there is one. Ask even when only "All my + repos" is on offer — it says what is about to be scanned. +3. "Just this branch's PR" → 3.2 with that one URL. +4. Otherwise call + `list_my_unlinked_prs(limit=25)` — adding `repo="<owner/name>"` for "This + repo". It returns the caller's own PRs that have **no confirmed session + link**, newest first, with PRs the user dismissed in MemHub already left + out. Do not pass `state`, `since` or `include_dismissed`. +5. Read the reply, **in this order**: + - **The tool is not available** (not in your tool list, or an unknown-tool + error) **or it failed** → say in one sentence that this MemHub backend + cannot list unlinked PRs yet (or the error, briefly), and use the + fallback below. + - **`github_connected: false`** → the org has no usable GitHub connection, + and `link_pr` would refuse every PR — whatever else the reply says. Relay + that with the `connect_url` and **stop**. + - **`github_identity_linked: false`** → MemHub does not know which GitHub + account is theirs, so it cannot tell which PRs they authored. Say so + once, give the `connect_url` verbatim as where to link it, and use the + fallback below. + - **`prs` is empty** → "no unlinked PRs of yours in <scope>". Stop. + - Otherwise take every `prs[].html_url` into 3.2. Keep each PR's + `legacy_branch_sessions` for 3.3, and the reply's `offset`, `count`, + `total` and `has_more` for 3.5. + +**Fallback: the user's own GitHub CLI.** `gh` cannot know MemHub's links or +dismissals, so the user picks. + +```bash +gh search prs --author=@me --limit 30 --sort created --order desc \ + --json url,title,repository,state,createdAt +``` + +Add `--repo <owner/name>` when they chose "This repo". If `gh` is missing, +unauthenticated or fails, **ask** them to paste the PR URLs or numbers instead +and resolve those as in step 1. + +Show a numbered list — `n. owner/repo#num — title (state, created date)` — and +ask which to map: numbers, ranges, `all` or `none`. This is always a text +reply, on every host: there can be thirty rows, and AskUserQuestion holds four. +Take at most 25 of the chosen URLs into 3.2. + +### 3.2 Collect the facts and scan — in the terminal, never in context + +Hand the URLs to the scanner, one `--pr-url` each. It reads each PR's head +branch, base branch, creation date, files and commits with `gh` **by URL** — +never by bare number, because `gh pr view <n>` resolves the number against the +CURRENT checkout and would rank sessions against a different pull request than +the one being linked. It checks that the `url` `gh` answers with is the one it +asked about, pages the file list through `gh api … --paginate` when a PR has +100 files or more, and then parses every local transcript **once**, scoring it +against every PR. + +```bash +python3 "<plugin-root>/skills/link-pr/scripts/find_sessions.py" \ + --pr-url "<url 1>" --pr-url "<url 2>" +``` + +A full batch reads 25 PRs from GitHub and a few hundred transcripts, which +takes tens of seconds to a few minutes. Give the command a **long timeout** +(ten minutes on Claude Code's Bash tool; the longest your host allows +elsewhere), and do not start it in the background and then guess its result. +(`--prs-from <file>`, one URL per line, takes the same list from a file.) + +It prints one JSON object: `prs[]` in the order given — each with `pr_url`, +`repo`, `number`, `title`, `head_ref`, `base_ref` and ranked `candidates` — +plus `skipped_prs[]` (`{pr_url, reason}`) and `sessions_scanned`. It never +prints transcript text. Candidates are already limited to sessions in that +PR's repository, because file paths match by suffix and an unrelated project's +`README.md` must not take a slot from someone who wrote the code. + +**Read stderr and `skipped_prs` too.** A transcript too large to parse is not +scanned (`note: N session(s) larger than … were not scanned`) — tell the user +which, since a long session is exactly the kind that did the work. A +`files_truncated` note means that PR was scored on part of its file list. +Report skipped PRs by reason: `gh_failed` (gh could not read it), `url_mismatch` +(gh answered for a different PR), `url_redirected` (the repo was renamed or +transferred — the entry carries `canonical_url`; say so and offer to map that +URL instead), `no_files`, `invalid_url`, `over_cap`, `unsupported_host` (not +on github.com — MemHub cannot link enterprise GitHub PRs yet). Exit +code 2 with `gh not found` means the GitHub CLI is required to read PR facts — +say so and stop. + +What a candidate's score is made of, so you can explain a row rather than +quote a number: + +| Signal | Points | +|---|---| +| a PR commit sha appears in the session | 5 — this is proof, not inference | +| each distinct PR file the session edited | 2, capped at 8 | +| the session ran on the PR's head branch | 3 | +| the session was last active between 30 days before the PR was opened and a day after | 1 | +| ran on the base branch only, never the head, and edited none of the files | −5 | + +(A single known PR can still be scanned the old way — +`--files-from <file> --branch … --base … --sha … --created-at … --repo …` — +and that mode is unchanged. `--find` uses the batch mode above.) + +### 3.3 One consolidated review + +1. **Already linked.** PRs that came from `list_my_unlinked_prs` have no + confirmed link by definition. **Every other PR** — named after `--find`, + this branch's PR, or picked from the `gh` list — gets + `link_pr(pr_url="…")` with **no** `session_ids` first: the probe. Its + `linked_sessions` mixes confirmed links with MemHub's old branch guesses: + drop from that PR's candidates only the rows whose `link_source` is **not** + `branch`, mentioning them as "already linked". A `branch` row is a guess + that still needs a real link — treat it exactly like a + `legacy_branch_sessions` entry in the next step. If the probe says + `github_connected: false` or `repo_in_install: false`, drop that PR and + relay the reason (and `connect_url`) once. +2. **MemHub's branch guesses.** Compare each legacy entry's (and each probe + `branch` row's) **`session_id`** + with the candidates' `conversation_id` — both are the capture client's + session id. (`conv_id` is MemHub's own row id: it never equals a + candidate, and `link_pr` does not accept it.) A matching candidate gets the + note "MemHub guessed this from the branch". Legacy sessions the scan did + not find are listed under the PR as "unverified — branch guess, not found + on this machine". Neither changes a score or a recommendation. +3. **Show everything grouped by PR.** A header line per PR + (`owner/repo#n — title`), then one line per candidate: short session id, + host, when it ran, its cwd, and **the evidence that matched** — how many of + the PR's files it edited, whether it ran on the head branch, whether one of + the PR's commit shas appears in it. Mark as **recommended** the rows scoring + on **more than one signal** — one weak signal is a coincidence. List the + PRs with no candidates together at the end, one line each. Say plainly that + these are candidates. +4. **Ask once**: "Link the recommended (N sessions across M PRs)", "Let me + choose" (then take their reply naming session ids), or "Link none". **Link + nothing without an explicit yes**, and never expand the set beyond what the + user named. An unverified legacy session is linked only if the user names + it. + +### 3.4 Link the approved set + +One call per PR that has approved sessions: + +``` +link_pr(pr_url="…", session_ids=[approved…], link_source="session_found") +``` + +`session_found` records that the link came from a scan a human confirmed, +which is a different claim from `session_self` (the session was there) and +`manual` (a person named it outright). An unverified legacy session the user +named goes in a separate call for that PR with its `session_id` and +`link_source="manual"` — a person named it, and no scan found it. + +**Never send `pr_type` from `--find`.** The server takes a classification only +with `link_source="session_self"`, and for good reason: these sessions were +found by scanning, so this mode is in no position to say what the pull +request's work was. The session that opened the PR classifies it (the +plugin's own hook asks it to); a scan does not. + +Report each PR's reply as step 4 says, then: + +- the PRs that had no candidates — the code was written somewhere this machine + cannot see (a teammate's laptop, a session that predates capture, a host with + no local transcript); `/memhub:link-pr --session <id> <pr>` links one by + hand; +- the skipped PRs, by reason. + +### 3.5 The next page + +When the list had more (`has_more`): say how many remain (`total − offset − +count`) and offer the **next 25**. The list is newest first and live: a PR +leaves it once it is linked or dismissed, so re-running from the start would +show the ones this machine had nothing for again, and a plain `offset + 25` +would skip as many unseen PRs as this page linked. On a yes, call +`list_my_unlinked_prs` again with the same `repo` and `offset=<offset + +count − PRs linked from this page>`, and continue from 3.2. Stop when there is +no `has_more` or the user says so. + +`--find` never dismisses a PR. Dismissing one hides it from the list; that +happens in MemHub, not here. + ## 4. Report the reply honestly -Relay what the server actually said; do not re-word a partial result into a -success it does not claim. +Every mode, per PR. Relay what the server actually said; do not re-word a +partial result into a success it does not claim. - `linked[]` entry with `created: true` → "linked". - `upgraded: true` → "upgraded an old inferred link to a confirmed one". diff --git a/plugins/memhub/skills/find-contributing-sessions/scripts/find_sessions.py b/plugins/memhub/skills/link-pr/scripts/find_sessions.py similarity index 100% rename from plugins/memhub/skills/find-contributing-sessions/scripts/find_sessions.py rename to plugins/memhub/skills/link-pr/scripts/find_sessions.py diff --git a/plugins/memhub/skills/login/SKILL.md b/plugins/memhub/skills/login/SKILL.md index 100ac216..eb7f07fc 100644 --- a/plugins/memhub/skills/login/SKILL.md +++ b/plugins/memhub/skills/login/SKILL.md @@ -129,9 +129,10 @@ from the next session. Only if the user needs them in this one: `/mcp` → needed). Cursor and Codex sign their tools in separately (Codex: `codex mcp login memhub`). -`$MEMHUB_TOKEN` authenticates the hooks and scripts but not the MCP tools: -Claude Code strips credential variables from a plugin's helper. A headless setup -that relies on it still needs a stored key for the tools. +`$MEMHUB_TOKEN` alone authenticates the hooks and scripts but not the MCP +tools: Claude Code strips credential variables from a plugin's helper. Running +login (not `--status`) with it set to an `mhk_` key stores that key for the +tools too, with no browser; that is what a cloud or CI setup should run. ## After a successful first login diff --git a/plugins/memhub/skills/pr-babysit/SKILL.md b/plugins/memhub/skills/pr-babysit/SKILL.md index c49b960d..aec345fd 100644 --- a/plugins/memhub/skills/pr-babysit/SKILL.md +++ b/plugins/memhub/skills/pr-babysit/SKILL.md @@ -1,7 +1,7 @@ --- -description: Use when a PR should be babysat to green — poll its review bots (Cursor bugbot, OpenAI Codex) and CI, fix the real findings, push, and when clean save a PR review record to the repo's MemHub room (e.g. "babysit this PR", "watch PR 14 and fix the bot findings", or, on Claude Code only, auto-armed by the memhub hook right after `gh pr create` — Codex and Cursor do not run that hook, so there it is started by hand). Designed as the body of a self-paced /loop — one poll→fix→push pass per invocation; the final pass writes the memory and ends the loop. +description: Use when a PR should be babysat to green — poll its review bots (any bot reviewer — Cursor BugBot, OpenAI Codex, the MemHub reviewer, others) and CI, fix the real findings, push, and when clean propose a team rule for any finding that taught something the next PR should not repeat (e.g. "babysit this PR", "watch PR 14 and fix the bot findings", or, on Claude Code only, auto-armed by the memhub hook right after `gh pr create` — Codex and Cursor do not run that hook, so there it is started by hand). Designed as the body of a self-paced /loop — one poll→fix→push pass per invocation; the final pass proposes rules (usually none) and ends the loop. argument-hint: "[pr-number-or-url]" -allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" show *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" set *), Bash(git remote get-url origin), Bash(git rev-parse --path-format=absolute --git-common-dir), Bash(gh pr view *), Bash(gh api repos/*/comments --paginate), Bash(gh api repos/*/reviews --paginate), Bash(gh api repos/*/replies -f body=*), Bash(git pull), Bash(git add *), Bash(git commit *), Bash(git push), Edit(./**), mcp__plugin_memhub_memhub__list_agent_brains, mcp__plugin_memhub-staging_memhub__list_agent_brains, mcp__plugin_memhub_memhub__create_agent_brain, mcp__plugin_memhub-staging_memhub__create_agent_brain, mcp__plugin_memhub_memhub__save_artifact, mcp__plugin_memhub-staging_memhub__save_artifact, mcp__plugin_memhub_memhub__list_orgs, mcp__plugin_memhub-staging_memhub__list_orgs, Read, Glob, Grep' +allowed-tools: 'Bash(git remote get-url origin), Bash(gh pr view *), Bash(gh api repos/*/comments --paginate), Bash(gh api repos/*/reviews --paginate), Bash(gh api repos/*/replies -f body=*), Bash(git pull), Bash(git add *), Bash(git commit *), Bash(git push), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_verify.py" *), Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/rulebook_conflicts.py" *), Write(//tmp/*.json), Edit(//tmp/*.json), Edit(./**), mcp__plugin_memhub_memhub__list_rules, mcp__plugin_memhub-staging_memhub__list_rules, mcp__plugin_memhub_memhub__list_rulebooks, mcp__plugin_memhub-staging_memhub__list_rulebooks, mcp__plugin_memhub_memhub__create_rule, mcp__plugin_memhub-staging_memhub__create_rule, Read, Glob, Grep' --- **Plugin root:** commands below use `${CLAUDE_PLUGIN_ROOT}`. Claude Code @@ -10,139 +10,133 @@ from `$PLUGIN_ROOT` when that is set, otherwise (e.g. on Cursor) to this plugin's root — the ancestor directory of this skill file that contains `.claude-plugin/` — with `export CLAUDE_PLUGIN_ROOT="<plugin-root>"`. -Babysit a pull request until its review bots are satisfied, then bank what -was learned into team memory. Each invocation is ONE pass; state between -passes (handled comment ids, the room id, pass counters) lives in the -loop's conversation context — re-derive nothing that an earlier pass -already resolved. +Babysit a pull request until its review bots are satisfied, then turn what +the review proved into proposed team rules. Each invocation is ONE pass; +state between passes (handled comment ids, each finding's verdict and fix +commit, pass counters) lives in the loop's conversation context — re-derive +nothing that an earlier pass already resolved. ## Every pass 1. **Resolve the PR.** From `$ARGUMENTS` (number or URL) or, absent that, `gh pr view --json number,url,state,headRefName` on the current branch. PR merged or closed → report that and END the loop (no further passes). -2. **Resolve the repo's room** (first pass only — reuse the id afterwards). - `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" show --json` prints the - cached entry when the repo is already cached — take its `brain_id` (the - room) and `org_id` (the org that owns it, when recorded; keep it as - `ORG_ID`) and skip the lookup. Otherwise: name `Repo: <org>/<name>` from - `git remote get-url origin` (host and `.git` stripped), ask - `list_agent_brains(repo="<org>/<name>")` (it looks across every org you are - in) and take the row whose name matches EXACTLY — a teammate may have - created it; use theirs. No match → - `create_agent_brain` with `category: "repo"`, a description, and - `repo: "<org>/<name>"` (omit `workspace_id`); its "already exists" and - "requires an org admin" answers are handled as in - `references/repo-brain.md` §3. A brain you create is private to you — say - so in the final report, since teammates will not see the review record - until it is shared. Either way, persist what you resolved with - `room_map.py set --brain-id <id>` (add `--org-id <org-id>` when the brain - row or `create_agent_brain`'s answer names its org) so later passes and - the artifact writers route without repeating this lookup. Edge cases (SSH remotes, - no remote, worktrees, not a git repo) and the create-time rules — resolve - before create, required description, report where it landed — are in - `${CLAUDE_PLUGIN_ROOT}/references/repo-brain.md`. -3. **Collect findings** (`{owner}/{repo}` and `{n}` from step 1): - - `gh pr view <n> --json state,mergeable,statusCheckRollup` +2. **Collect findings** (`{owner}/{repo}` and `{n}` from step 1): + - `gh pr view <n> --json state,mergeable,mergeStateStatus,statusCheckRollup,commits` - `gh api repos/{owner}/{repo}/pulls/{n}/comments --paginate` (inline review comments), `.../pulls/{n}/reviews --paginate` (review bodies), `.../issues/{n}/comments --paginate` (top-level comments). - - A finding is: a comment/review from a bot reviewer — login containing - `cursor` or `bugbot` (Cursor BugBot) or `codex`/`chatgpt` (OpenAI - Codex), typically with a `[bot]` suffix — or a FAILING required check - in `statusCheckRollup`. Skip comment ids already handled in a previous - pass. -4. **Triage and fix.** For each new finding, read the code it points at and + - A finding is an inline review comment, or a review with a non-empty + body, whose author is a bot — `user.type == "Bot"` — or a FAILING + required check in `statusCheckRollup`. Match on the account type, never + on a list of bot names: a repo's own reviewer app is a bot too, and a + name list is how one went unread for two passes. Top-level comments from + a bot are findings only when they review the code; a status post (a + ticket link, a deploy notice, a summary of the PR) is not. + - Skip: comment ids already handled in a previous pass; reviews with an + empty body (replying to a thread creates one, on the new head, a minute + after the push — they are your own replies, not a re-review); a bot's + own reply withdrawing an earlier finding. + - A comment from a **person** is not triaged here: the person decides + what they meant. Note each unresolved human thread for the final + report. +3. **Triage and fix.** For each new finding, read the code it points at and judge it — bots are wrong often enough that "a bot said so" is not a reason to change code. - Real → fix it on the PR's head branch (check it out if HEAD moved; `git pull` first; NEVER force-push). One commit per finding or one per - coherent batch, message naming what the bot caught. - - False positive → record the rejection rationale for step 6, and - best-effort reply to the comment thread with one line of why - (`gh api repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies -f body=...`; + coherent batch, message naming what the bot caught. Note the verdict + and the fix commit; the final pass reads them. + - False positive → best-effort reply to the comment thread with one line + of why (`gh api repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies -f body=...`; if the reply fails, move on — it's cosmetic). - Push once at the end of the pass, after all of the pass's commits. -5. **Decide: another pass, or done?** +4. **Decide: another pass, or done?** - Pushed fixes this pass → NOT clean; the bots need time to re-review. End the turn so the loop re-wakes; bots typically take a few minutes, so self-pace around 4–5 minutes. - Clean = a pass that pushed nothing AND found no new findings AND no - required check is failing or pending on the head commit AND the bots - have had their review window: at least one bot review/comment exists - for the current head commit, OR ~20 minutes have passed since that - commit was pushed (its `committedDate` from - `gh pr view --json commits` vs now — review bots that are going to - comment usually do within ~20 minutes). Right after `gh pr create` - neither holds, so an immediate first pass can never end the loop. - First clean pass after any push → proceed to step 6. + required check is failing or pending on the head commit AND the PR + still merges (`mergeable` is not `CONFLICTING`) AND the bots have had + their review window: at least one bot review/comment exists for the + current head commit, OR ~20 minutes have passed since that commit was + pushed (its `committedDate` from `gh pr view --json commits` vs now — + review bots that are going to comment usually do within ~20 minutes). + Right after `gh pr create` neither holds, so an immediate first pass + can never end the loop. First clean pass after any push → the final + pass. + - `CONFLICTING` → the base moved under the PR. Say so and end the loop + without the final pass: resolving a conflict is the person's call, not + a finding to fix. - Safety valve: findings still arriving after ~10 passes, or the same finding reopening repeatedly → stop looping, summarize the impasse to - the user, and still do step 6 with what happened so far. + the user, and still run the final pass with what happened so far. -## Final pass — save the process to MemHub, then end the loop +## Final pass — propose rules, then end the loop -Save a **PR-scoped artifact** — the review record — into the repo's room. -Do NOT import the session transcript (step 3 says why). +The review just proved something no other moment proves as well: a finding +was real, and a commit fixed it. When the mistake behind it would recur on +another PR, that is a rule. **None is the usual answer** — most findings are +one-off defects in one diff. Do not import the session transcript and do not +save a review record: capture already ships the session to the user's +personal memory, and a rule is the only thing from this review the team +needs to see again. -What capture does not record is the judgment: which findings were real, -which were rejected and why, and which commit answered each. That is this -step's whole value, and it is a page of text. +1. **Pick the lessons.** Take each finding you judged real and fixed, and + keep it only if all four hold: + - **The mistake will recur.** It is a way of writing code, testing it or + changing it — not a typo, a missing case in this one function, or a + fact about this PR. + - **A rule would have prevented it**: something an agent can be told + before or while it writes the code, in one sentence. + - **It is general enough to state without this diff.** Name the file, + symbol or table only when the lesson lives there (then the rule is + scoped to this repo). + - **No rule already says it.** Step 2 of create-rule checks this. -1. **Compose the review record.** Write it yourself from the pass history in - this loop's context — you read every finding and made every call, so - nothing needs re-deriving. Keep it to what a future reader needs: - - **PR** — url, title, head branch, final state. - - **Findings** — one entry each: the bot, the finding in one line, the - verdict (fixed / rejected), and the fix commit SHA or the rejection - rationale. Rejections matter MORE than fixes here; they are the part - no diff records. - - **Patterns worth carrying** — a bot's false-positive tendency, a - finding created by an earlier fix, a repo-specific trap. Skip this - section rather than padding it. -2. **Save it** into the repo's room with `save_artifact`: - `name: "PR review record — <owner>/<repo>#<n>"`, - `content: <the record from step 1>`, `artifact_type: "document"`, - `tags: ["pr-review", "<repo>"]`, `topic: <the subject area the PR - changed>`, `agent_brain_id: <repo-room-id-from-step-2>`, and - `org_id: <ORG_ID>` when step 2 has one. + Two findings with one cause are one lesson. A finding caused by your own + earlier fix in this loop counts only if the cause is the general one + (e.g. "the fix was not tested against the unfixed code"), never the + specific bug. - `topic` is the brain chapter the record belongs under — the area of the - code the PR changed (`billing`, `rulebook`), never `pr_review` (that is - what the record IS, and the server refuses it as a topic). Use an existing - topic of the brain when one fits even loosely; a new one only for an area - none covers; else `"unsorted"`. If the save is refused for a missing or - unusable topic, the refusal lists the brain's topics — pick one and save - again. A later re-save of the same record keeps its topic. - (`${CLAUDE_PLUGIN_ROOT}/references/topics.md`) +2. **File each lesson** by Reading + `${CLAUDE_PLUGIN_ROOT}/skills/create-rule/SKILL.md` and following it, on + the **unattended path** in + `${CLAUDE_PLUGIN_ROOT}/skills/create-rule/references/harness.md`: nobody + is asked anything, step 0 files it for the user alone (`scope: + "personal"`) unless anyone working in the repo needs it, as that file + says, step + 4b (the live test) does not run, and a conflict it says not to file is + not filed. What differs from a harness fork: + - `source: "nomination"` with `author: "xtrace"` — the review, not a + person, wrote it — and **no** `state` stamp (that is a harness fork's + alone). + - `source_ref: "<the PR url>#<the finding's comment id>"`, exactly; a + re-file under the same ref is answered `unchanged`. + - `why` names the finding and the fix commit: what went wrong on this PR. + - A **dismissed** rule that says the same thing is a reviewer's "no" + (create-rule step 2 lists retired rules for exactly this): file + nothing for that lesson. The server refuses a dismissed twin only for + harness drafts, so here the check is yours. - The stable `name` is load-bearing: saving it again VERSIONS the record, - so a later babysit of the same PR supersedes the earlier one instead of - competing with it in retrieval. + It lands `proposed`; a reviewer activates it. Never pass `activate`. - `save_artifact` failing with the brain not found means the cached id is - stale — the server resolves a brain's org from its id, so it is no longer - a wrong-org lookup. Recover ONCE: re-run the step-2 - `list_agent_brains(repo="<org>/<name>")` lookup; on a match with a - different id, re-cache it with `room_map.py set` and retry. Still failing - → report the error in step 4 rather than retrying. -3. **Never import the transcript.** Per-turn capture already ships this - session as it happens, into the user's personal memory — never into a - brain, the repo's room included. Importing it again re-uploads megabytes - for the watermark to discard, and lands in the same personal memory. - The review record above is what reaches the room: that artifact, not the - session, is how the team sees this PR's outcome. -4. Add one short top-level outcome note IN THE REPORT to the user: PR url - and title, branch, findings per bot with accepted/rejected counts, and - any repo-specific gotcha or bot false-positive tendency observed. +3. **Report** to the user, plain English: + - PR url and title, final state, findings per reviewer with + fixed / rejected counts. + - Each rule proposed (its title, who it applies to — the reply's + `label` — and who activates it: the user for a personal rule, an org + admin for an org one), or "no rule from this + review" — and for each real finding you did not turn into a rule, a + few words on why (one-off, already a rule). + - Each unresolved human thread, by its first line. -Then report to the user (PR state, what was fixed, where the memory went) -and END the loop: call `ScheduleWakeup` with `stop: true` so no further +Then END the loop: call `ScheduleWakeup` with `stop: true` so no further wake-up is scheduled. -Plain-English output throughout. If the memory save fails on authentication, do -the fixing anyway and send the user to `/memhub:login` — **not** `/mcp`. The -save runs through the plugin's own credential (the personal access key -`/memhub:login` mints), which is a separate store from the `/mcp` connector's -token, so a connected `/mcp` does not mean the save can authenticate. Never fail -the babysit over it. +If filing fails on authentication, the fixing still stands: report the +lessons you would have filed and send the user to `/memhub:login` — **not** +`/mcp`. The plugin's tools run on the personal access key `/memhub:login` +mints, a separate store from the `/mcp` connector's token, so a connected +`/mcp` does not mean the filing can authenticate. Never fail the babysit over +it. diff --git a/plugins/memhub/skills/repos/SKILL.md b/plugins/memhub/skills/repos/SKILL.md new file mode 100644 index 00000000..cb73d075 --- /dev/null +++ b/plugins/memhub/skills/repos/SKILL.md @@ -0,0 +1,64 @@ +--- +description: Use when the user wants to choose which repositories MemHub is active in, or asks why MemHub is quiet in one (e.g. "enable memhub for this repo", "turn memhub off in client repos", "which repos is memhub on for", "memhub says it is inactive here", "/memhub:repos add ."). Lists, adds or removes repositories on this machine's allowlist, or clears it so MemHub is active everywhere. Outside the listed repositories MemHub does nothing — no team rules, no session capture, no companion. +argument-hint: "[list | add [<org/name>|.] | remove [<org/name>|.] | all | status]" +allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" *)' +--- + +**Plugin root:** commands below use `${CLAUDE_PLUGIN_ROOT}`. Claude Code and +Codex export it automatically; if it is unset (e.g. on Cursor), set it first to +this plugin's root — the ancestor directory of this skill file that contains +`.claude-plugin/` — with `export CLAUDE_PLUGIN_ROOT="<plugin-root>"`. + +MemHub can be limited to the repositories a person chooses. The list lives on +this machine in `~/.config/memhub-plugin/repos.json`: + +- **No file:** MemHub is active in every repository (how it behaves unless + someone made a list). +- **A file:** MemHub is active only in the repositories it lists. Everywhere + else the plugin is dormant: no team rules fire, no session or document is + captured, and the companion stays hidden. A session started in such a + repository is told so once, at its start. An **empty** list means dormant + everywhere — the installer writes one when it cannot ask. + +A repository is named as its `origin` remote spells it, `<org>/<name>` +(`XTraceAI/MemHub-Backend`); a checkout with no remote is named by its +directory. `.` means the current checkout, and the script derives the name, so +nobody has to spell it — from a worktree too, which carries its repository's +name, not its branch directory's. + +The file may also hold **declined** repositories: in Claude Code, a session +started in a checkout the list leaves out is asked once, on the status strip +above the prompt (`MemHub · enable in <org/repo>?` — `1 Yes / 2 No / 3 Later`). +No records the repository as declined: as dormant as any unlisted one, and never +asked about again. `list` and `status` show declined repositories; `add` lists +one and clears its decline; `remove` of a declined repository clears the decline +only, so the strip asks again there. + +Arguments: `$ARGUMENTS` + +Run exactly one command and report what it prints: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" list # default: what is listed, and this checkout's state +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" add . # list the current repository +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" add XTraceAI/xmem # list one by name +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" remove . # take the current repository off the list +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" all # delete the list: active everywhere +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/repos.py" status # one line for this checkout +``` + +Three things to say when they apply: + +- The **first** `add` creates the list, which turns MemHub off in every other + repository on this machine. The script says so; repeat it, and name + `/memhub:repos all` as the way back. +- A change reaches the Python hooks at the next tool call. The Claude Code mod + (the in-process rules and the companion) reads the list at session start, so + after `add` or `remove` the person restarts the session, or `/reload-plugins`. +- A repository shown as **declined** is one the person answered No to on the + strip. If they now want MemHub there, `add .` is the way; say that `remove` + would only make the strip ask again. + +Do not edit `repos.json` by hand and do not suggest disabling the plugin for +one repository: disabling it stops capture and rules in every repository, +which is what this list exists to avoid. diff --git a/plugins/memhub/skills/save-artifact/SKILL.md b/plugins/memhub/skills/save-artifact/SKILL.md index d339af0e..91dded42 100644 --- a/plugins/memhub/skills/save-artifact/SKILL.md +++ b/plugins/memhub/skills/save-artifact/SKILL.md @@ -1,5 +1,5 @@ --- -description: Use when the user asks to save, store, or upload a file/document/spec to MemHub or team memory as an artifact (e.g. "save this spec to memhub", "store this doc as an artifact", "version this design doc in memhub"). Uploads the file's bytes via a terminal script — never call save_artifact directly or re-emit file contents. +description: Use when the user wants a file kept in team memory where teammates and their agents can find it (e.g. "save this spec to memhub", "store this doc for the team", "upload this design doc", "save a new version of this runbook"). Uploads the file as is — to the repo's shared brain when there is one, otherwise to the user's own memory — and versions it when a document of the same name already exists. argument-hint: "<file-path> [artifact name]" allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/save_artifact.py" *), mcp__plugin_memhub_memhub__search_memory, mcp__plugin_memhub-staging_memhub__search_memory, mcp__plugin_memhub_memhub__list_tags, mcp__plugin_memhub-staging_memhub__list_tags' --- @@ -14,7 +14,7 @@ helper script — **for content that already exists on disk, do NOT call the `save_artifact` MCP tool yourself and do NOT paste/retype the file contents**; that would regenerate the whole document token by token. This is a terminal operation, like `cat`-ing a file. Content you compose in-session and that -lives nowhere on disk (a handoff brief, a PR review record) is the other case: +lives nowhere on disk (a handoff brief) is the other case: there, calling `save_artifact` directly is fine — nothing is being re-emitted. Arguments: `$ARGUMENTS` diff --git a/plugins/memhub/skills/search-memory/SKILL.md b/plugins/memhub/skills/search-memory/SKILL.md index 7de544e6..6c8aad3a 100644 --- a/plugins/memhub/skills/search-memory/SKILL.md +++ b/plugins/memhub/skills/search-memory/SKILL.md @@ -1,7 +1,7 @@ --- -description: Use when the user asks what the team knows, decided, discussed, or saved about a topic, or wants to check MemHub/team memory (e.g. "what do we know about X", "did we decide on Y", "search memhub for Z", "is there a spec for W"). Read-only — walks a brain's overview, searches artifacts, sessions and documents, and opens what it finds. +description: Use when the user asks what the team knows, decided or wrote down about something, or what they themselves did in an earlier session (e.g. "what do we know about X", "did we decide on Y", "is there a spec for Z", "what did I do on this last week", "search memhub for W"). Read-only. Looks in the team's shared brain and in the user's own memory, opens the best match, and says which one the answer came from — or plainly that nothing was found. argument-hint: "<what to look for>" -allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" show *), mcp__plugin_memhub_memhub__get_brain_overview, mcp__plugin_memhub-staging_memhub__get_brain_overview, mcp__plugin_memhub_memhub__search_memory, mcp__plugin_memhub-staging_memhub__search_memory, mcp__plugin_memhub_memhub__read_memory, mcp__plugin_memhub-staging_memhub__read_memory, mcp__plugin_memhub_memhub__list_agent_brains, mcp__plugin_memhub-staging_memhub__list_agent_brains, mcp__plugin_memhub_memhub__list_tags, mcp__plugin_memhub-staging_memhub__list_tags, mcp__plugin_memhub_memhub__list_sessions, mcp__plugin_memhub-staging_memhub__list_sessions' +allowed-tools: 'Bash(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" show *), mcp__plugin_memhub_memhub__get_brain_overview, mcp__plugin_memhub-staging_memhub__get_brain_overview, mcp__plugin_memhub_memhub__search_memory, mcp__plugin_memhub-staging_memhub__search_memory, mcp__plugin_memhub_memhub__read_memory, mcp__plugin_memhub-staging_memhub__read_memory, mcp__plugin_memhub_memhub__list_agent_brains, mcp__plugin_memhub-staging_memhub__list_agent_brains, mcp__plugin_memhub_memhub__list_sessions, mcp__plugin_memhub-staging_memhub__list_sessions' --- **Plugin root:** commands below use `${CLAUDE_PLUGIN_ROOT}`. Claude Code and @@ -9,89 +9,37 @@ Codex export it automatically; if it is unset (e.g. on Cursor), set it first to this plugin's root — the ancestor directory of this skill file that contains `.claude-plugin/` — with `export CLAUDE_PLUGIN_ROOT="<plugin-root>"`. -Search MemHub team memory and report what it holds about the user's topic. -Read-only: this skill never writes or modifies memory. +Answer `$ARGUMENTS` (or what the user just asked) from MemHub. Read-only. The +tool schemas document every parameter; this is the judgment they don't carry. +Climb a ladder, cheapest rung first, and never answer from a rung that only +points at the answer: -Arguments: `$ARGUMENTS` — what to look for, in natural language. If empty, -derive the query from what the user just asked. +1. **Find the repo's brain, then read its map.** The session-start brief names + it (`MemHub: this repo's agent brain is …`); otherwise + `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" show --json` prints the + cached `brain_id`, or `list_agent_brains(repo="<org>/<name>")` finds it. For + another brain the user names, `list_agent_brains(query="…")`; when you don't + know which brain holds it, search with `all_brains=true`. Call + `get_brain_overview(agent_brain_id)` — a question its Index already answers + needs no search. +2. **Search for pointers.** `search_memory` returns POINTERS only, never a + body; `kind`: `"artifact"` is the default. Run the SAME query twice and + merge: once with `agent_brain_id`, once without it (personal memory). Every + captured session's gist and episodes live in personal memory, never in a + brain — skip that call and the answer reads as "the team never wrote that + down". **Episodes are never searched with `agent_brain_id`** (refused), so + `kind="episode"` goes in the personal call. For "what did I do last week / + on that PR", start from `list_sessions`. Browsing (empty `query`) is for + walking an Index group past its `… +N more` line, not for "what's new": + `as_of` stamps can be refreshed by batch jobs, so newest-first is not + recent work. A thin result: rephrase once or twice before concluding. +3. **Open the one you picked with `read_memory(id)`.** Open ONE; never answer + from an abstract. For a long artifact call it bare first for the outline, + then again with the one `section_id` that answers the question. Don't use + `include_content=true` to skip this step. -Do exactly this — a ladder, cheapest rung first: **overview → pointers → -open one**. Never answer from a rung that only points at the answer. - -1. **Find the brain, then read its map.** In a repo with an agent brain, - search that brain first. The SessionStart brief names it (`MemHub: this - repo's agent brain is …`); `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/room_map.py" show --json` - prints the cached `brain_id` if the brief is not in context, and - `list_agent_brains(repo="<org>/<name>")` finds it on the server. When the - user names a DIFFERENT brain, `list_agent_brains(query="…")` ranks the - brains you can read. Then call `get_brain_overview(agent_brain_id)`: its - Index groups the brain's artifacts (Specs, Runbooks, Design, …) with - counts, and its lines say `read_memory(id)` for the ones worth opening - directly. A question the Index already answers needs no search. -2. **Search for pointers.** Call `search_memory` with a natural-language - `query` (phrase it as the thing you want to find, not keywords). It returns - POINTERS only — `{id, kind, title, abstract, author, as_of, links, tags, - score}` plus, for an artifact, up to 3 ranked `sections` — never a body. - Useful parameters: - - `kind`: `"artifact"` (**the default**) | `"document"` | `"episode"` | - `"all"`. Artifacts are the documents the team wrote — specs, briefs, - runbooks, review records — and are what a question about how something - works is usually answered by. `"episode"` for what happened in one of - YOUR past sessions, `"document"` for the chunked text of an ingested - file, `"all"` for every kind. There are no facts to search. - - `agent_brain_id`: the brain from step 1. Omitting it searches your - personal memory instead and reads as "the team never wrote that down". - Then run the SAME query again WITHOUT `agent_brain_id` and merge: widen, - never replace. Personal memory holds what the repo brain does not — - including every captured session's gist and episodes, which go to - personal memory, never to a brain. **Episodes are never searched with - `agent_brain_id`** (the call is refused): `kind="episode"` always goes - in the personal call. Skip the second call only when the user asked - about the repo/team specifically. - - `all_brains=true`: when you don't know which brain holds it, searches - every brain you can read in one call (use instead of `agent_brain_id`). - `folder` narrows that to the brains filed under one folder. - - `top_k`: raise from the default 8 (max 50) when the user wants everything - on a topic. - - `tags` (+ `match`: `"all"`/`"any"`): narrows to artifacts carrying the - tag(s) — check the vocabulary with `list_tags` first. Tags are stored - normalised (lowercase, non-alphanumeric runs → `_`, 64 chars max): a spec - saved with `spec:retry-policy` is listed as `spec_retry_policy`. The - filter normalises your input the same way, so either spelling matches. - - `created_after` / `created_before`: ISO-8601 bounds on when the memory - was *captured* (not when the underlying event happened). - - `group` / `author`: artifact filters that mirror the brain's Index — - `group` is one of `"Specs"`, `"Runbooks"`, `"Design"`, `"Briefs"`, - `"Routine output"`, `"Documents"`; `author` is a teammate's display name. - `author` naming anyone but you needs `agent_brain_id` — personal memory - holds only your own. - - `session_id`: the episodes of one session. For "what did I do last - week / in that repo / on that PR", list the sessions first with - `list_sessions(since=…, until=…, repo=…, pr_url=…)` and open one with - `read_memory(<session id>)`. - - **Browsing** — leave `query` EMPTY to list rather than search: you get - the matching items of any `kind` newest first, `top_k` per page, - `offset=top_k` for the next page. This is how you walk an Index group - past the `… +N more` line. A page shorter than `top_k` is the last one. - `offset` WITH a query is an error — a search is ranked, not paged. -3. **Open the one you picked with `read_memory(id)`.** A pointer carries no - body. The kind comes from the id — the same call opens an artifact (with - its version header), a document chunk, an episode, or a whole session by - session id; include `agent_brain_id` when the id came from a brain search. For - a long artifact call it once bare for the OUTLINE (one line per section - with a `section_id`), then again with `section_id=…` for the one section - that answers the question; that is normally a tenth of the tokens of the - whole document. Do not answer from abstracts alone, and do not reach for - `include_content=true` on the search to dodge this step — it brings every - hit's body back at once, which is what pointers exist to avoid. It is the - right escape hatch only when you truly need several bodies at speed. -4. If the first search comes back thin, retry once or twice with a rephrased - query or a different `kind` before concluding the memory isn't there. -5. Answer the user's question from what you opened, citing which memories - support it (kind + a short quote). Say where each hit came from — the - repo's brain or your personal memory — because "the team decided this" - and "I noted this once" are different claims. If nothing relevant exists, - say so plainly — do not pad with loosely related hits. - -Plain-English output only: never surface internal ids, scores, or field names -unless the user asks for them. +Answer from what you opened, with a short quote, and say where each piece came +from — the team's brain or the user's personal memory: "the team decided" and +"I noted this once" are different claims. If nothing relevant exists, say so +plainly; don't pad with loosely related hits. Plain English: no ids, scores or +field names unless asked. diff --git a/plugins/memhub/skills/start-rulebook/SKILL.md b/plugins/memhub/skills/start-rulebook/SKILL.md index 3aaae717..779bd12d 100644 --- a/plugins/memhub/skills/start-rulebook/SKILL.md +++ b/plugins/memhub/skills/start-rulebook/SKILL.md @@ -112,6 +112,12 @@ parses. what was read and how many older transcripts were not — repeat it to the user. (Claude Code itself deletes transcripts after about 30 days unless `cleanupPeriodDays` was raised, so for most people `--all` adds little.) +- **What a session counts as.** Its own transcript plus its subagents' + (their commands and edits count; their prompts are not the person's + corrections). It belongs to the repo of its cwd, remembered across runs, so + a session in a worktree deleted since still counts for its repo. The + report's `subagents:` and `repo placement:` lines say how many of each. + `--repo` takes the repo's name or any path inside it. - Inputs it takes: `--days N` / `--all`, `--claude-md <path>` (repeatable), `--candidates <json list>` (repeatable: the checks you derive in step 2), `--rule-file <body>` (one check — what `create-rule` calls for its backtest), `--facets <file @@ -130,17 +136,22 @@ parses. everything except the `create_rule` calls. **Decide who the rules apply to before you file anything — one scope for the -whole batch.** Every rule goes into one scope: `org` (everyone in the -organisation — **the default**), `personal` (just the user), or `workspace` -(the people in one workspace — only when the user asks for it). Call -`list_rulebooks` and read its `scopes` block (`org` / `personal` with `label` -and `applies_to`, `supports_workspaces`, `workspaces[]`); do not enumerate -books. `--scope` given → use it. The user's words say "just me" / "only me" / -"personal" → `personal`. They name a workspace or teamspace that matches a -`scopes.workspaces[].name` and `supports_workspaces` is true → `workspace` with -that `workspace_id`. **Otherwise `org`** — do not ask, and never offer the -workspace scope unprompted; a workspace they name that is not listed (or a -plan without workspaces) is said once, and the batch goes to `org`. Every +whole batch.** Every rule goes into one scope: `personal` (just the user — +**the default**), `org` (everyone in the organisation — only with a strong +reason), or `workspace` (the people in one workspace — only when the user asks +for it). Call `list_rulebooks` and read its `scopes` block (`org` / `personal` +with `label` and `applies_to`, `supports_workspaces`, `workspaces[]`); do not +enumerate books. `--scope` given → use it. Their words say "just me" / "only me" / "personal" → `personal`, never escalated. They name a workspace or teamspace +that matches a `scopes.workspaces[].name` and `supports_workspaces` is true → +`workspace` with that `workspace_id`. Their words put the rules on everyone +("the team", "everyone", "org-wide") → `org`. **Otherwise `personal`** — +unless you can say in one sentence why EVERY rule in the batch is right for +anyone working in this repo or org (it guards something shared, or a repo +fact that bites every contributor), not just this user's machine, tools, +habits or taste; then `org`, with that sentence in the proposal. Do not ask, +and never offer the workspace scope unprompted; a workspace they name that is +not listed (or a plan without workspaces) is said once, and the batch goes to +`personal` unless the rest of this paragraph says `org`. Every proposal you show the user names who it would apply to (the scope's `label` and `applies_to`), because that is who the rule would reach. @@ -249,8 +260,11 @@ leaves: **Show the WHAT THE SCAN FOUND block and let them correct it.** It is heuristics over file names. The two it gets wrong most: the default branch when the team merges somewhere other than `origin/HEAD` (a `staging` flow), -and the test command when the repo wraps it (`make check`, `nox`, `tox`). On -a correction, edit the slot in `signals.json` and re-run `seed` then `verify` +and the test command when the repo wraps it (`make check`, `nox`, `tox`). For +the branch, re-run `all` with `--default-branch` once per branch to guard +(`--default-branch main --default-branch staging`); when pull requests land on +another branch, the scan's branch line names it and gives that command. For +anything else, edit the slot in `signals.json` and re-run `seed` then `verify` — never hand-edit a candidate's regex. **`rc=1` means a candidate failed; it is not offered and not filed.** Say diff --git a/plugins/memhub/skills/start-rulebook/scripts/mine_sessions.py b/plugins/memhub/skills/start-rulebook/scripts/mine_sessions.py index df99d6b2..880b21cf 100644 --- a/plugins/memhub/skills/start-rulebook/scripts/mine_sessions.py +++ b/plugins/memhub/skills/start-rulebook/scripts/mine_sessions.py @@ -14,18 +14,21 @@ Friction delta: --baseline-date splits facet friction before/after a rulebook change. Stdlib + the memhub plugin's readers + the real hook evaluate() (never re-implemented). +A session is its own transcript plus its subagents' (their calls fire rules too; their prompts are not the person's), +placed in a repo by its cwd — remembered across runs, so a deleted worktree still counts for its repo. +Replays run in parallel where the OS can fork; the report is the same either way. Window: the last 30 days of sessions by default (--days N), by each transcript's LAST activity. --all reads everything and is for when the person asks for it: a rule should answer to how the team works now, and an old corpus is slow to read. -Usage: mine_sessions.py [--out DIR] [--days N | --all] [--baseline-date YYYY-MM-DD] [--skills-file list_skills.json] [--repo NAME] +Usage: mine_sessions.py [--out DIR] [--days N | --all] [--baseline-date YYYY-MM-DD] [--skills-file list_skills.json] [--repo NAME|PATH] """ -import sys, os, json, re, glob, collections, importlib.util, time, argparse, datetime, shlex, functools +import sys, os, json, re, glob, collections, importlib.util, time, argparse, datetime, shlex, functools, subprocess ap = argparse.ArgumentParser() ap.add_argument("--out", default="mine-out") ap.add_argument("--baseline-date", help="friction before vs after this date (rule activation day)") ap.add_argument("--skills-file", help="memhub list_skills JSON reply, for skill-lane dedup") -ap.add_argument("--repo", help="only sessions in this repo (resolved, so a worktree counts)") +ap.add_argument("--repo", help="only sessions in this repo: its name, or a path inside it (resolved, so a worktree counts)") ap.add_argument("--days", type=int, default=None, help="only sessions active in the last N days (default 30; widened automatically so a --baseline-date keeps 30 days of 'before')") ap.add_argument("--all", action="store_true", help="every session on this machine, however old — only when the person asks for it") ap.add_argument("--claude-md", action="append", default=[], help="CLAUDE.md (repeatable): its imperative sentences become the declared-rule seed") @@ -106,57 +109,151 @@ def _in_window(p): fresh = seen >= _cutoff skipped_old += not fresh return fresh +# Claude Code keeps its transcripts under CLAUDE_CONFIG_DIR when that is set, and reads ~/.claude only when it is not. +CLAUDE_DIR = os.path.expanduser(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude") def sessions(): - for host, pat in (("claude", "~/.claude/projects/*/*.jsonl"), ("codex", "~/.codex/sessions/*/*/*/rollout-*.jsonl"), + for host, pat in (("claude", os.path.join(CLAUDE_DIR, "projects", "*", "*.jsonl")), ("codex", "~/.codex/sessions/*/*/*/rollout-*.jsonl"), ("cursor", "~/.cursor/chats/*/*/store.db"), ("cursor", "~/.cursor/projects/*/agent-transcripts/*/*.jsonl")): for p in glob.glob(os.path.expanduser(pat)): if _in_window(p): yield host, p R = {"claude": claude, "codex": codex, "cursor": cursor} corpus, errs = [], collections.Counter() -def _repo_name(cwd): - """The repo a transcript's cwd belongs to. `repo_identity` lives beside the - hook, which this script already imports as `rh`. Not kept across runs: its - fallback (the directory's basename) cannot be told apart from a resolved - name, and a kept wrong name would drop sessions from `--repo` for good.""" +import repo_identity as _rid # noqa: E402 (beside the hook, on the path set above) +# cwd -> repo, kept across runs so a session still resolves once its worktree is deleted (most worktrees are, and a +# deleted one's cwd answers nothing). Only names git itself settled are kept: the basename fallback cannot be told +# apart from a real name, and a kept wrong name would drop sessions from `--repo` for good. +_repo_cache = _load_cache("repos.json", {}) +_resolved = {} +def _resolve(d): + if d not in _resolved: _resolved[d] = _rid.resolve(d) + return _resolved[d] +def _repo_of(cwd): + """(repo, how) for a transcript's cwd. A live directory asks git. A deleted one is read from the cache, else from + its nearest surviving parent when that is inside a repo (`<repo>/.claude/worktrees/<name>`). None when neither + answers; the branch test below may still place it.""" + cwd = cwd.rstrip("/") or "/" + if os.path.isdir(cwd): + name, settled = _resolve(cwd) + if settled: _repo_cache[cwd] = name + return name, "live" + if cwd in _repo_cache: return _repo_cache[cwd], "cache" + home, a = os.path.expanduser("~"), os.path.dirname(cwd) + while a not in ("", "/", home): + if os.path.isdir(a): + name, settled = _resolve(a) + if not settled: return None, "gone" + _repo_cache[cwd] = name # git answered for the parent; a branch match (below) is an inference and is not kept + return name, "parent" + a = os.path.dirname(a) + return None, "gone" +def _sniff(path): + """(cwd, branch) off a Claude Code transcript's first records — enough to place it in a repo without parsing it.""" + cwd = branch = None try: - from repo_identity import repo_name - return repo_name(cwd.rstrip("/")) - except Exception: - return os.path.basename(cwd.rstrip("/")) - - -for host, path in sessions(): - try: recs, meta = R[host].to_canonical(path) - except Exception: errs[host] += 1; continue + with open(path, encoding="utf-8", errors="replace") as f: + for i, line in enumerate(f): + if i >= 200 or (cwd and branch): break + try: r = json.loads(line) + except ValueError: continue + if not isinstance(r, dict): continue + cwd = cwd or (r.get("cwd") if isinstance(r.get("cwd"), str) else None) + b = r.get("gitBranch") + branch = branch or (b if isinstance(b, str) and b not in ("", "HEAD") else None) + except OSError: pass + return cwd, branch +def _walk(recs, keep_users=True): + """users, calls, results, result_calls, first timestamp — one transcript, as the hook would see its calls.""" users, calls, results, result_calls, ts = [], [], [], [], None call_by_use = {} for r in recs: ts = ts or r.get("timestamp") m = r.get("message") or {}; c = m.get("content") - if r.get("type") == "user" and isinstance(c, str) and not c.startswith("<"): users.append(c) + if keep_users and r.get("type") == "user" and isinstance(c, str) and not c.startswith("<"): users.append(c) if isinstance(c, list): for b in c: if not isinstance(b, dict): continue - if r.get("type") == "user" and b.get("type") == "text" and not str(b.get("text", "")).startswith("<"): users.append(b.get("text", "")) + if keep_users and r.get("type") == "user" and b.get("type") == "text" and not str(b.get("text", "")).startswith("<"): users.append(b.get("text", "")) if b.get("type") == "tool_use": - i = b.get("input") or {}; n = b.get("name", ""); cmd = ""; fp = str(i.get("file_path", "")); body = str(i.get("new_string", "")) + str(i.get("content", "")) + i = b.get("input") or {}; n = b.get("name", ""); cmd = ""; fp = str(i.get("file_path", "")); added = None + # The edit body and what it ADDED, built the way the live hook's pre lane builds them: a + # MultiEdit's `edits` count, and a content rule reads only the lines an edit added. + body = str(i.get("new_string", "")) + str(i.get("content", "")) + \ + "\n".join(str(e.get("new_string", "")) for e in (i.get("edits") or []) if isinstance(e, dict)) + if n in rh.EDIT_TOOLS and hasattr(rh, "edit_added_text"): added = rh.edit_added_text(i) if n == "Bash": cmd = str(i.get("command", "")) elif n in ("exec_command", "shell_command"): n = "Bash"; cmd = str(i.get("cmd") or i.get("command") or "") # Codex, as codex_hook_bridge maps it elif n == "apply_patch": n = "Write"; patch = str(i.get("input", "")); mm = re.search(r"\*\*\* (?:Update|Add) File: (.+)", patch); fp = mm.group(1).strip() if mm else ""; body = patch - calls.append({"tool": n, "cmd": cmd, "path": fp, "body": body, "n": len(calls)}) + calls.append({"tool": n, "cmd": cmd, "path": fp, "body": body, "added": added, "ts": r.get("timestamp") or "", "n": len(calls)}) if b.get("id"): call_by_use[b["id"]] = (n, cmd) if b.get("type") == "tool_result": cc = b.get("content"); results.append(cc if isinstance(cc, str) else " ".join(str(x.get("text", "")) for x in cc if isinstance(x, dict))) result_calls.append(call_by_use.get(b.get("tool_use_id")) or ("", "")) # empty where the host does not link them - cwd = meta.get("cwd") or (recs[0].get("cwd") if recs else "") or "" - # The REPO, not the directory: a session run in a worktree would otherwise - # propose rules scoped to the branch name, which bind nobody (the server - # matches `scope_repos` by exact string). - repo = _repo_name(cwd) if cwd else "?" + return users, calls, results, result_calls, ts +def _subagent_files(host, path): + """A Claude Code session's subagents write their own transcripts beside it: <session>/subagents/agent-*.jsonl.""" + return sorted(glob.glob(os.path.join(os.path.splitext(path)[0], "subagents", "*.jsonl"))) if host == "claude" else [] + +# Place every transcript in a repo BEFORE parsing it: a --repo run parses only its own repo's sessions. +placed, parsed, how = [], {}, collections.Counter() +for host, path in sessions(): + if host == "claude": cwd, branch = _sniff(path) + else: # Codex / Cursor carry their cwd in a format only the reader knows; they are few, so parse them now + try: parsed[path] = R[host].to_canonical(path) + except Exception: errs[host] += 1; continue + recs, meta = parsed[path]; cwd, branch = meta.get("cwd") or (recs[0].get("cwd") if recs else "") or "", None + repo, h = _repo_of(cwd) if cwd else ("?", "no cwd") + placed.append([host, path, cwd, branch, repo, h]) +# A deleted cwd no parent answers for: its branch, when exactly one live repo has a branch by that name. `main` +# is in every repo, so it places nothing — only a branch unique to one repo does. +_branch_sets = {} +def _branches(repo_dir): + if repo_dir not in _branch_sets: + try: + out = subprocess.run(["git", "-C", repo_dir, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads", "refs/remotes"], + capture_output=True, text=True, timeout=10).stdout.split() + except (OSError, subprocess.SubprocessError): out = [] + _branch_sets[repo_dir] = {b.split("/", 1)[1] if b.startswith("origin/") else b for b in out} + return _branch_sets[repo_dir] +_live_dir = {} +for row in placed: + if row[5] == "live" and row[4] not in _live_dir: _live_dir[row[4]] = row[2] +for row in placed: + if row[5] != "gone": continue + hits = [name for name, d in _live_dir.items() if row[3] and row[3] in _branches(d)] if row[3] else [] + row[4], row[5] = (hits[0], "branch") if len(hits) == 1 else (os.path.basename(row[2].rstrip("/")), "basename") +for row in placed: how[row[5]] += 1 +_save_cache("repos.json", _repo_cache) +if args.repo and (os.sep in args.repo or os.path.isdir(os.path.expanduser(args.repo))): # a path names its repo, as a session's cwd does + _want = _repo_of(os.path.abspath(os.path.expanduser(args.repo)))[0] or os.path.basename(args.repo.rstrip("/")) + print(f"--repo {args.repo} -> {_want}") + args.repo = _want +subagent_files = subagent_calls = 0 +for host, path, cwd, branch, repo, h in placed: if args.repo and repo != args.repo: continue + try: recs, meta = parsed.pop(path, None) or R[host].to_canonical(path) + except Exception: errs[host] += 1; continue + users, calls, results, result_calls, ts = _walk(recs) + stamp = f"{len(users)}u{len(calls)}c{len(results)}r" # the session's OWN turns: a facet is read off them, so folding subagents in leaves it fresh + main_calls, main_results = calls, results + # Subagent calls are the session's calls: the live hook fires on them, and an ordering receipt run inside a + # subagent discharges the parent's obligation (it is keyed by worktree, not agent). Their "user" turns are the + # parent's prompt, not the person, so they are left out. + subs = [] + for sp in _subagent_files(host, path): + try: srecs, _ = R[host].to_canonical(sp) + except Exception: continue + _, sc, sr, src, _ = _walk(srecs, keep_users=False) + subs.append((sc, sr, src)); subagent_files += 1; subagent_calls += len(sc) + if subs: + merged = calls + [c for sc, _, _ in subs for c in sc] + if all(c["ts"] for c in merged): merged.sort(key=lambda c: c["ts"]) # ISO stamps sort in time order; the sort is stable + calls = [dict(c, n=k) for k, c in enumerate(merged)] + results = results + [t for _, sr, _ in subs for t in sr] + result_calls = result_calls + [rc for _, _, src in subs for rc in src] sid = meta.get("session_id") or os.path.splitext(os.path.basename(path))[0] - corpus.append({"id": sid, "host": host, "repo": repo, "start": (ts or "")[:10], "users": users, "calls": calls, "results": results, "result_calls": result_calls}) + corpus.append({"id": sid, "host": host, "repo": repo, "start": (ts or "")[:10], "users": users, "calls": calls, "results": results, + "result_calls": result_calls, "main_calls": main_calls, "main_results": main_results, "stamp": stamp}) CORRECTION = re.compile(r"^(no|nope|wrong|wait|stop)\b|\b(not what i|why did (u|you)|did (u|you) (just )?|actually (read|test|run|check|do)|i said|i meant|revert that|undo that|is (all )?stale|u should|you should|read the (actual|real)|check the (live|actual|latest|agent|other)|this is (prod|staging)|not (prod|staging)|don'?t (code|merge|push|delete|guess)|plan first)\b", re.I) PASTED = re.compile(r"^(Base directory for this skill|Approach this as|<command-message>|<task-notification>|This session is being continued)", re.I) ERROR = re.compile(r"Traceback \(most recent call last\)|^Exit code [1-9]|\bexit code [1-9]\b|ModuleNotFoundError|FAILED \(|\d+ failed\b|Permission denied|command not found|<tool_use_error>", re.M) @@ -166,19 +263,21 @@ def digest(s): """Compact, LLM-readable summary of one session — user turns (corrections marked), errors, reverts. ~1-2k chars.""" turns = [u.strip().replace("\n", " ") for u in s["users"] if u.strip() and not PASTED.search(u.strip()) and len(u) < 1500] # skip skill preambles / pasted docs corr = [t for t in turns if CORRECTION.search(t[:200])] - errs = [t.replace("\n", " ")[:160] for t in s["results"] if ERROR.search(t)] - rev = [c["cmd"].replace("\n", " ")[:120] for c in s["calls"] if c["cmd"] and REVERT.search(c["cmd"])] - tools = collections.Counter(c["tool"] for c in s["calls"]) + errs = [t.replace("\n", " ")[:160] for t in s["main_results"] if ERROR.search(t)] + rev = [c["cmd"].replace("\n", " ")[:120] for c in s["main_calls"] if c["cmd"] and REVERT.search(c["cmd"])] + tools = collections.Counter(c["tool"] for c in s["main_calls"]) stds = [t for t in turns if STANDARD.search(t[:300])] score = 3 * len(corr) + min(len(errs), 10) + 2 * len(rev) + 2 * min(len(stds), 5) return {"session_id": s["id"], "stamp": _stamp[s["id"]], "host": s["host"], "repo": s["repo"], "start": s["start"], "score": score, "first_prompt": (turns[0] if turns else "")[:300], "user_turns": [{"i": i, "correction": bool(CORRECTION.search(t[:200])), "standard": bool(STANDARD.search(t[:300])), "text": t[:220]} for i, t in enumerate(turns[:25])], "tool_counts": dict(tools), "errors": errs[:4], "reverts": rev[:4]} -_stamp = {s["id"]: f"{len(s['users'])}u{len(s['calls'])}c{len(s['results'])}r" for s in corpus} # grows when a session does — a resumed turn, or a result that landed after its call: a facet read off an older stamp is stale +_stamp = {s["id"]: s["stamp"] for s in corpus} # grows when a session does — a resumed turn, or a result that landed after its call: a facet read off an older stamp is stale digests = sorted((digest(s) for s in corpus), key=lambda d: -d["score"]) M = len(corpus); by_host = collections.Counter(s["host"] for s in corpus) print(f"sessions read: {dict(by_host)} (M={M}) read errors: {dict(errs)} ({time.time()-t0:.0f}s)") +print(f"subagents: {subagent_files} subagent transcripts folded into their sessions ({subagent_calls} tool calls)") +print("repo placement: " + ", ".join(f"{k} {v}" for k, v in how.most_common()) + " (live = cwd still on disk; cache / parent / branch = a deleted cwd placed; basename = a deleted cwd nothing placed)") print("window: every session on this machine (--all)" if WINDOW_DAYS is None else f"window: sessions active in the last {WINDOW_DAYS} days — {skipped_old} older transcripts not read (any repo). --days N widens it; --all reads everything") if WINDOW_DAYS is None or not 1 <= WINDOW_DAYS <= 365: # the server takes window_days 1..365 (ENG-1158); another window is not rounded to fit @@ -252,12 +351,14 @@ def _facet_in_window(d): if WINDOW_DAYS is None and not args.repo: return True # --all lifts the AGE cutoff only; a --repo run is still about that repo sid = str(d.get("session_id") or "") return bool(sid) and (sid in _in_corpus or (len(sid) >= 8 and any(full.startswith(sid) for full in _in_corpus))) -insights_skipped = 0 -for f in glob.glob(os.path.expanduser("~/.claude/usage-data/facets/*.json")): # optional extra seed if Claude Code /insights was ever run +insights_skipped = insights_dup = 0 +for f in glob.glob(os.path.join(CLAUDE_DIR, "usage-data", "facets", "*.json")): # optional extra seed if Claude Code /insights was ever run try: d = json.load(open(f)); d.setdefault("source", "insights") except Exception: continue - if _facet_in_window(d): facets.append(d) - else: insights_skipped += 1 + if not _facet_in_window(d): insights_skipped += 1 + elif corpus_id(d.get("session_id")) in _fresh: insights_dup += 1 # your facet already reads this session: a second reading would count its friction twice + else: facets.append(d) +if insights_dup: print(f"facets: {insights_dup} /insights facets left out — your own facets already read those sessions") if insights_skipped: print(f"window: {insights_skipped} /insights facets left out — their sessions are outside the window (or no longer on disk)") _start_full = {s["id"]: s["start"] for s in corpus} def start_of_id(sid): @@ -469,10 +570,10 @@ def replay(rule, requires_prior_rx=None): except Exception: ok = False if ok: break else: - tool_n = "Bash" if cl["tool"] == "Bash" else ("Write" if cl["tool"] in EDIT else None) + tool_n = cl["tool"] if cl["tool"] == "Bash" or cl["tool"] in EDIT else None # the real tool: a `write_stdlib` rule fires on Write alone if not tool_n: continue if requires_prior_rx and cl["cmd"] and re.search(requires_prior_rx, cl["cmd"]): prior = True - try: ok = rh.evaluate(rule, hook_phase="pre", tool=tool_n, cmd=cl["cmd"], file_path=cl["path"], body=cl["body"]) + try: ok = rh.evaluate(rule, hook_phase="pre", tool=tool_n, cmd=cl["cmd"], file_path=cl["path"], body=cl["body"], added=cl.get("added")) except Exception: ok = False if ok: calls[s["host"]] += 1 @@ -669,26 +770,76 @@ def replay_result(rule): return {"calls": dict(calls), "fired": dict(sess), "fired_n": sum(sess.values()), "fired_ids": ids, "real_misses": None, "samples": ex} + +# ---- every replay at once. Each is one rule over the whole corpus and shares nothing with the next, so they run in +# forked workers that inherit the corpus instead of being sent it. The results land in a cache the three functions +# read first: the pool is only ever a head start, and with no fork (Windows) or any failure each replay simply runs +# where it is asked for, as before. +import inspect, multiprocessing # noqa: E402 +_replayed, _JOBS = {}, [] +def _replay_key(fn, a): + bound = inspect.signature(fn).bind(*a); bound.apply_defaults() + return fn.__name__, json.dumps(list(bound.arguments.values()), sort_keys=True, default=str) +def _cached(fn): + @functools.wraps(fn) + def w(*a): + k = _replay_key(fn, a) + if k not in _replayed: _replayed[k] = fn(*a) + return _replayed[k] + w.raw = fn + return w +replay, replay_result, replay_ordering = _cached(replay), _cached(replay_result), _cached(replay_ordering) +def _run_job(i): + fn, a = _JOBS[i] + return fn.raw(*a) +def _prefetch(jobs): + jobs = list({_replay_key(fn.raw, a): (fn, a) for fn, a in jobs if _replay_key(fn.raw, a) not in _replayed}.values()) + workers = min(len(jobs), os.cpu_count() or 1, 6) # each worker's copy of the corpus grows as it reads it; six keeps that bounded + if workers < 2 or "fork" not in multiprocessing.get_all_start_methods(): return + _JOBS[:] = jobs + try: + with multiprocessing.get_context("fork").Pool(workers) as pool: + for (fn, a), res in zip(jobs, pool.map(_run_job, range(len(jobs)), chunksize=1)): + _replayed[_replay_key(fn.raw, a)] = res + except Exception as e: + print(f"[warn] parallel replay unavailable ({type(e).__name__}: {e}) — replaying one rule at a time", file=sys.stderr) + finally: + _JOBS.clear() + + # ---- rules already on: did they fire in your past sessions? -# With --repo, THIS repo's cached book alone. The cache also holds books for other -# repos and for e2e fixtures, and unioning them credits this repo with rules that -# never reach it — a stale fixture book put "Name variables in camelCase" into a -# Python repo's panel with 112 calls behind it. -book_files = ([rh.book_path(args.repo)] if args.repo and hasattr(rh, "book_path") - else glob.glob(os.path.join(getattr(rh, "BASE", os.path.expanduser("~/.config/memhub-plugin/rulebook")), - "book", "*.json"))) # this install's books only (keyed by backend) -book = [] +# The books of the repos these sessions are in — with --repo, that repo's alone. The cache also holds books for +# throwaway repos and e2e fixtures, fetched once and never again: unioning them credits these sessions with rules +# that never reach them (a stale fixture book put "Name variables in camelCase" into a Python repo's panel with 112 +# calls behind it), and keeps replaying a rule long after it was superseded. +if hasattr(rh, "book_path"): + book_files = [f for f in dict.fromkeys(rh.book_path(r) for r in ([args.repo] if args.repo else sorted({s["repo"] for s in corpus}))) + if os.path.isfile(f)] +else: + book_files = glob.glob(os.path.join(getattr(rh, "BASE", os.path.expanduser("~/.config/memhub-plugin/rulebook")), "book", "*.json")) +book_files.sort(key=lambda f: os.path.getmtime(f)) # oldest first, so the latest fetch of a rule is the one kept +seen = {} for f in book_files: - try: book += json.load(open(f)).get("rules", []) - except Exception: pass -seen = {}; live = [] -for r in book: # several cached books (one per repo) can hold different versions of one rule: keep the newest per title - title = r.get("title") if isinstance(r, dict) else None - if not title or r.get("delivery") != "agent_hook": continue # a partial/corrupt cache row is skipped, not fatal - try: ver = int(r.get("version") or 0) - except (TypeError, ValueError): ver = 0 - if title in seen and seen[title] >= ver: continue - seen[title] = ver; live = [x for x in live if x.get("title") != title] + [r] + try: rules = json.load(open(f)).get("rules", []) + except Exception: continue + for r in rules: # several repos' books can hold one rule at different versions; a superseding rule keeps the title + title = r.get("title") if isinstance(r, dict) else None + if not title or r.get("delivery") != "agent_hook": continue # a partial/corrupt cache row is skipped, not fatal + seen.pop(title, None); seen[title] = r +live = list(seen.values()) +_jobs = [] # what the two report sections below will ask for, in the same argument forms +for r in live: + hr = rh.to_hook_rule(r); on = (hr or {}).get("on") + if on in ("bash", "edit", "write_stdlib", "result"): _jobs.append((replay_result, (hr,)) if on == "result" else (replay, (hr,))) + elif on == "ordering" and isinstance(hr.get("ordering"), dict): + o = hr["ordering"]; lane = arming_lane(o.get("armed_by_events") or ["edit"]) + if lane != "prompt": _jobs.append((replay_ordering, (o.get("required_command_rx") or "", o.get("gated_command_rx") or "", lane, o.get("min_edits", 1)))) +for c in RULE_CANDS: + hr = hook_rule(c["title"], c["matcher"]) + if hr: _jobs.append((replay, (hr, c.get("requires_prior_rx")))) +for c in ORDERING_CANDS: + if c["armed_by"] != "prompt": _jobs.append((replay_ordering, (c["required_rx"], c["gated_rx"], c["armed_by"], c.get("min_edits", 1)))) +_t_replay = time.time(); _prefetch(_jobs) print(f"\n=== RULES ALREADY ON — {len(live)} hook rules in your book, replayed over your {M} past sessions (one that never fired is a retire candidate)") for r in live: hr = rh.to_hook_rule(r); on = (hr or {}).get("on") @@ -799,7 +950,7 @@ def covers(r, d, fr): # per ITEM: the rule would have fired in that session AN # ---------------------------------------------------------------- skills your team keeps retyping installed = set() -for pat in ("~/.claude/plugins/cache/*/*/*/skills/*/SKILL.md", "~/.claude/plugins/*/plugins/*/skills/*/SKILL.md", "~/.claude/skills/*/SKILL.md", ".claude/skills/*/SKILL.md", "~/.codex/skills/*/SKILL.md", "~/.cursor/skills/*/SKILL.md"): +for pat in (os.path.join(CLAUDE_DIR, "plugins/cache/*/*/*/skills/*/SKILL.md"), os.path.join(CLAUDE_DIR, "plugins/*/plugins/*/skills/*/SKILL.md"), os.path.join(CLAUDE_DIR, "skills/*/SKILL.md"), ".claude/skills/*/SKILL.md", "~/.codex/skills/*/SKILL.md", "~/.cursor/skills/*/SKILL.md"): installed |= {os.path.basename(os.path.dirname(p)) for p in glob.glob(os.path.expanduser(pat))} if args.skills_file: try: installed |= {x["name"] for x in json.load(open(args.skills_file)).get("skills", [])} @@ -904,6 +1055,6 @@ def to_ere(rx): open(os.path.join(grabs_dir, "Makefile.suggested"), "w").write("\n".join(mk)) print(f"\ngrabs written to {grabs_dir}/: claude-md-additions.md, hooks.settings.json, Makefile.suggested — copyable now, filable in the report step") -json.dump([{k: v for k, v in s.items() if k != "results"} for s in corpus], open(os.path.join(args.out, "corpus.json"), "w")) +json.dump([{k: v for k, v in s.items() if k not in ("results", "main_calls", "main_results")} for s in corpus], open(os.path.join(args.out, "corpus.json"), "w")) # `calls` already holds the session's own json.dump(proposals + proposals_seed, open(os.path.join(args.out, "proposals.json"), "w"), indent=1) print(f"\nwrote {args.out}/corpus.json and {args.out}/proposals.json ({len(proposals)} candidates) in {time.time()-t0:.0f}s") diff --git a/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py b/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py index 4eeeb8ad..bffb15e4 100644 --- a/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py +++ b/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py @@ -39,6 +39,7 @@ import subprocess import sys import tempfile +import time from collections import Counter from pathlib import Path @@ -56,15 +57,28 @@ # Written without a quantified group: the hook's load lint drops those. # `/usr/bin/git` is the same program: a path in front of it is not a way round every git rule. GIT = CMD + r"(?:sudo\s+)?(?:\S*/)?git\s+(?:-[cC]\s*\S+\s+)?" -RX_MAX = 400 +# How a Python tool is launched besides by its bare name: through uv or poetry, options and all +# (`uv run --extra dev pytest`), uvx (`uvx ruff check`), a venv path (`.venv/bin/pytest`) +# or the interpreter (`uv run python -m pytest`, `.venv/bin/python3.12 -m mypy`). A receipt that knows +# only the bare name is a gate the team's own green run never clears: on one repo, 2,082 of 2,290 +# pytest runs were `uv run python -m pytest`. PY_RUN alone is the runner, for `python manage.py test`. +# The option group IS quantified, but holds no quantified group of its own, so the load lint keeps it. +PY_RUN = r"(?:(?:uv|poetry) run(?: -\S+(?: \w\S*)?)* |uvx )?" +PY_LAUNCH = PY_RUN + r"(?:\S*/)?(?:(?:python[\d.]*|\$\w+) -m )?" # `$PY -m pytest` too +# The longest pattern the hook loads, read from the hook itself: a copy here sat at 400 for a week +# after the hook (and the server's MAX_REGEX) moved to 2000, and narrowed every receipt for nothing. +sys.path.insert(0, str(PLUGIN_SCRIPTS)) +from rulebook_hook import _RX_MAX as RX_MAX # noqa: E402 +_PY_LAUNCHED = ("pytest", "tox", "nox") # runners whose pattern is the bare tool, launched via a launcher # What the server's rule judge reads when a rule's pattern matches (rule-judge-spec §2): the situation # the rule is for, the situations it is not for, what it asks, and why. create_rule refuses a field # over its cap, it does not truncate — so the caps are held here, before anything is filed. WHEN_MAX, DO_MAX, WHY_MAX, WHEN_NOT_MAX, WHEN_NOT_ENTRIES = 300, 400, 400, 200, 8 # Appended to a command pattern to make it a RECEIPT: the command, but not an invocation that exits 0 # having done none of the work — `pytest --version`, `eslint --help`, `pytest --collect-only`. -_RAN_NOTHING = (r"\b(?![^|;&]*\s(?:--version|-V|--help|-h|--collect-only|--co|--fixtures|--markers|--setup-plan" - r"|--listenvs|--list|-list|--showconfig|--show-settings|--print-config|--no-run)(?:\s|=|$))") +# Factored, not shortened: the same flags, written once each. +_RAN_NOTHING = (r"\b(?![^|;&]*\s(?:--version|-[Vh]|--help|--co(?:llect-only)?|--fixtures|--markers|--setup-plan" + r"|--list(?:envs)?|-list|--show(?:config|-settings)|--print-config|--no-run)(?:\s|=|$))") _SKIP_DIRS = ("node_modules/", "vendor/", "dist/", "build/", ".venv/", "venv/", "__pycache__/", ".git/", "target/", ".next/", "site-packages/") @@ -107,7 +121,7 @@ def _alt(parts) -> str: def _fit(prefix: str, extras: list[str], suffix: str = "") -> str: """Append alternatives while the whole pattern stays loadable. The hook - drops a rule whose pattern passes 400 characters, silently, so a long + drops a rule whose pattern passes RX_MAX characters, silently, so a long repo-derived list is cut here rather than shipped dead.""" rx = prefix for e in extras: @@ -119,7 +133,41 @@ def _fit(prefix: str, extras: list[str], suffix: str = "") -> str: # ───────────────────────────── scan ───────────────────────────── -def scan(repo: Path) -> dict: +_PR_SUBJECT = re.compile(r"^(?:Merge pull request #\d+\b.*|.*\(#\d+\)\s*)$", re.M) + + +def _pr_landing_branches(repo: Path, skip: str) -> list[str]: + """Other remote branches pull requests land on, active in the last 14 days: of its last ten + first-parent commits that origin/HEAD lacks, at least three, and 60 % of them, are merge-button + merges or squashes (`… (#123)`). The tip alone is not evidence — a merge bot that versions the + PR branch leaves a `(#123)` commit on every feature branch — but a feature branch's own commits + are its author's. A HINT for the person, + never a slot: guarding a branch nobody merges to would block every push to it, so the scan names + these and the person passes --default-branch to add one.""" + # Newest first, then capped: an alphabetical cap cut `staging` off a 276-branch remote. + rows = _git(repo, "for-each-ref", "--sort=-committerdate", "--count=200", + "--format=%(refname:short)\t%(committerdate:unix)\t%(contents:subject)", "refs/remotes/origin") + cutoff = time.time() - 14 * 86400 + out = [] + for row in rows.splitlines(): + ref, _, rest = row.partition("\t") + when, _, subject = rest.partition("\t") + name = ref.split("/", 1)[-1] + if "/" not in ref or name in ("HEAD", skip) or not when.isdigit() or int(when) < cutoff: + continue + if not _PR_SUBJECT.fullmatch(subject.strip()): + continue # cheap pre-filter: an integration branch's tip is a landing too + # Only commits the default branch lacks: a feature branch cut a few commits ago inherits the + # default branch's history, which is all landings. Just after a release an integration branch + # has none of its own and goes unnamed; the flag still adds it. + own = _git(repo, "log", "--first-parent", "-n", "10", "--format=%s", "refs/remotes/origin/HEAD.." + ref) + n = len([s for s in own.splitlines() if s.strip()]) + if n >= 3 and len(_PR_SUBJECT.findall(own)) >= 0.6 * n: + out.append(name) + return out + + +def scan(repo: Path, default_branches: list[str] | None = None) -> dict: files = _tracked(repo) fset = set(files) live = [f for f in files if not any(("/" + f).find("/" + d) >= 0 for d in _SKIP_DIRS)] @@ -139,12 +187,23 @@ def missing(key: str, why: str) -> None: slots["repo"] = re.sub(r"\.git$", "", remote.rstrip("/").rsplit("/", 1)[-1].rsplit(":", 1)[-1]) \ if remote else repo.resolve().name head = _git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") - if head: - branch = head.split("/", 1)[-1] - found("branch", "origin/HEAD → %s" % branch, value=branch) - slots["default_branch_rx"] = "^(%s)$" % re.escape(branch) - slots["default_branch"] = branch - slots["default_branch_esc"] = re.escape(branch) + branches = list(dict.fromkeys(b.strip() for b in default_branches or [] if b.strip())) + if branches: + found("branch", "--default-branch → %s" % ", ".join(branches), value=branches[0], branches=branches) + elif head: + branches = [head.split("/", 1)[-1]] + also = _pr_landing_branches(repo, branches[0]) + found("branch", "origin/HEAD → %s" % branches[0] + ( + "; pull requests also land on %s — to guard it too, scan again with --default-branch %s" + % (", ".join(also), " --default-branch ".join(branches + also)) if also else ""), + value=branches[0], branches=branches, also_landing=also) + if branches: + # All three slots come from the one list, so correcting the branch is one value, not three + # hand-edits that must agree. `_esc` sits inside a larger pattern, so it is a group. + esc = [re.escape(b) for b in branches] + slots["default_branch_rx"] = "^(%s)$" % "|".join(esc) + slots["default_branch"] = branches[0] + slots["default_branch_esc"] = esc[0] if len(esc) == 1 else "(?:%s)" % "|".join(esc) else: # No guess. `main|master` on a repo whose default is `trunk` yields a verified gate # that guards nothing — worse than no rule, because it reads as protection. The two @@ -202,18 +261,20 @@ def runner(name, rx, example, targeted="", slow_flag="", slow_example=""): before = len(runners) if names["pytest.ini"] or names["conftest.py"] or re.search(r"\bpytest\b", py_cfg): flags = [r"--cov\b"] + ([r"-m\s+[\"']?(?:%s)" % "|".join(map(re.escape, slow))] if slow else []) - runner("pytest", r"(?:uv run |poetry run |python3? -m )?pytest", "pytest", py_targeted, + runner("pytest", r"pytest", "pytest", py_targeted, "|".join(flags), "pytest --cov=app") # --cov and -m are pytest's flags, nobody else's if names["tox.ini"] or "[tool.tox" in py_cfg: - runner("tox", r"(?:uv run |python3? -m )?tox\b", "tox", py_targeted) + runner("tox", r"tox\b", "tox", py_targeted) if names["noxfile.py"]: - runner("nox", r"(?:uv run |python3? -m )?nox\b", "nox", py_targeted) + runner("nox", r"nox\b", "nox", py_targeted) if names["manage.py"] and len(runners) == before: - runner("django", r"python3? manage\.py test", "python manage.py test", py_targeted) + runner("django", PY_RUN + r"(?:\S*/)?python[\d.]* manage\.py test", + "python manage.py test", py_targeted) if len(runners) == before: test_files = [f for f in live if re.search(r"(^|/)test_[^/]+\.py$", f)][:20] if any("unittest" in _read(repo / f, 4000) for f in test_files): - runner("unittest", r"python3? -m unittest", "python -m unittest", py_targeted) + runner("unittest", PY_RUN + r"(?:\S*/)?python[\d.]* -m unittest", + "python -m unittest", py_targeted) if "node" in chains: deps = {**(pkg.get("dependencies") or {}), **(pkg.get("devDependencies") or {})} if isinstance(pkg, dict) else {} node_targeted = r"\s\S*\.(?:test|spec)\.|\s\S*(?:tests?|__tests__)/|\s-t\s|--testPathPattern" @@ -249,12 +310,16 @@ def runner(name, rx, example, targeted="", slow_flag="", slow_example=""): else: missing("toolchain", "no pyproject.toml / package.json / go.mod / Cargo.toml") if runners: # no evidenced runner → no slot → every rule that needs one is dropped - slots["test_cmd_rx"] = _alt(r["rx"] for r in runners) + # pytest / tox / nox share ONE launcher group (as the Python linters do), so it is written once. + launched = [r["rx"] for r in runners if r["name"] in _PY_LAUNCHED] + others = [r["rx"] for r in runners if r["name"] not in _PY_LAUNCHED] + slots["test_cmd_rx"] = _alt(([PY_LAUNCH + _alt(launched)] if launched else []) + others) + receipt = slots["test_cmd_rx"] + _RAN_NOTHING slots["targeted_rx"] = "|".join(r["targeted"] for r in runners if r["targeted"]) or r"\s--this-repo-has-no-targeted-form\b" slots["test_cmd_example"] = runners[0]["example"] # What CLEARS a "tests ran" obligation. A targeted run does, on purpose; an invocation that runs no # test does not — `pytest --version`, `--help`, `--collect-only` all exit 0 having tested nothing. - slots["test_receipt_rx"] = slots["test_cmd_rx"] + _RAN_NOTHING + slots["test_receipt_rx"] = receipt if slow_content: slots["test_slow_content_rx"] = "|".join(c for c, _ in slow_content) slots["test_slow_example"] = slow_content[0][1] @@ -271,17 +336,27 @@ def runner(name, rx, example, targeted="", slow_flag="", slow_example=""): else: missing("markers", "no test runner with a known slow or coverage tier") - # lint tooling (repo entrypoint first, raw tool names second) - dev_text = (cfg + _read(repo / ".pre-commit-config.yaml") + json.dumps(pkg) - + "".join(_read(repo / n) for n in fset if re.fullmatch(r"requirements[^/]*\.txt", n))) + # lint tooling (repo entrypoint first, raw tool names second). CI workflows and the Makefile count as + # evidence too: a linter the CI runs is one the team runs, whether or not it is a dev dependency. + ci_files = [f for f in live if re.fullmatch(r"\.github/workflows/[^/]+\.ya?ml", f) + or f in (".gitlab-ci.yml", ".circleci/config.yml")][:20] + dev_text = (cfg + _read(repo / ".pre-commit-config.yaml") + json.dumps(pkg) + makefile + + "".join(_read(repo / n) for n in fset if re.fullmatch(r"requirements[^/]*\.txt", n)) + + "".join(_read(repo / f, 100_000) for f in ci_files)) lint, lint_ex = [], [] if "lint" in make_targets: lint.append("make lint"); lint_ex.append("make lint") if "lint" in scripts: lint.append(r"(?:npm|pnpm|yarn|bun) run lint"); lint_ex.append("%s run lint" % pm) - for tool, rx, ex in (("ruff", r"(?:uv run )?ruff (?:check|format)", "ruff check ."), ("black", "black", "black --check ."), - ("flake8", "flake8", "flake8"), ("mypy", "mypy", "mypy ."), ("pylint", "pylint", "pylint src"), - ("eslint", r"(?:npx )?eslint", "npx eslint ."), ("prettier", r"(?:npx )?prettier", "npx prettier --check ."), + # The Python tools share ONE launcher group, so it is written once. + py_lint = [(rx, ex) for tool, rx, ex in (("ruff", r"ruff (?:check|format)", "ruff check ."), + ("black", "black", "black --check ."), ("flake8", "flake8", "flake8"), + ("mypy", "mypy", "mypy ."), ("pylint", "pylint", "pylint src")) + if re.search(r"\b%s\b" % re.escape(tool), dev_text)] + if py_lint: + lint.append(None) # the launcher goes in once the rest of the alternation is known + lint_ex += [ex for _, ex in py_lint] + for tool, rx, ex in (("eslint", r"(?:npx )?eslint", "npx eslint ."), ("prettier", r"(?:npx )?prettier", "npx prettier --check ."), ("biome", r"(?:npx )?biome", "npx biome check"), ("typescript", r"(?:npx )?tsc", "npx tsc --noEmit"), ("golangci-lint", "golangci-lint", "golangci-lint run")): if re.search(r"\b%s\b" % re.escape(tool), dev_text): @@ -292,11 +367,13 @@ def runner(name, rx, example, targeted="", slow_flag="", slow_example=""): lint.append(r"cargo (?:clippy|fmt)"); lint_ex.append("cargo clippy") if lint: found("lint", ", ".join(lint_ex), value=lint_ex) - slots["lint_cmd_rx"] = _alt(lint) + py_rx = r"(?:%s)\b" % "|".join(rx for rx, _ in py_lint) if py_lint else "" + slots["lint_cmd_rx"] = _alt([PY_LAUNCH + py_rx if x is None else x for x in lint]) slots["lint_receipt_rx"] = slots["lint_cmd_rx"] + _RAN_NOTHING # `eslint --version` checked no code slots["lint_cmd_example"] = lint_ex[0] else: - missing("lint", "no linter or formatter in dev dependencies, pre-commit, Makefile or package scripts") + missing("lint", "no linter or formatter in dev dependencies, pre-commit, Makefile, CI workflows " + "or package scripts") # manifests + lock tool manifest_names = ["pyproject.toml", "package.json", "go.mod", "Cargo.toml", "Gemfile"] @@ -859,6 +936,9 @@ def main() -> int: ap.add_argument("--all-repos", action="store_true", help="leave scope_repos empty so the rules bind every repo the rulebook's members work in") ap.add_argument("--replay", help="attach-evidence: the proposals.json of the mine_sessions.py run that replayed these candidates") + ap.add_argument("--default-branch", action="append", default=[], metavar="NAME", + help="scan/all: a branch the push rules guard, in place of origin/HEAD (repeatable: " + "--default-branch main --default-branch staging guards both)") args = ap.parse_args() out = Path(args.out) if args.step == "attach-evidence": @@ -879,7 +959,7 @@ def main() -> int: catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8")) if args.step in ("scan", "all"): - signals = scan(Path(args.repo)) + signals = scan(Path(args.repo), args.default_branch) (out / "signals.json").write_text(json.dumps(signals, indent=2)) else: signals = json.loads((out / "signals.json").read_text()) diff --git a/tests/claude_hook_guard_test.py b/tests/claude_hook_guard_test.py index 10a5292f..6c0e16d7 100644 --- a/tests/claude_hook_guard_test.py +++ b/tests/claude_hook_guard_test.py @@ -80,11 +80,16 @@ def test_every_claude_handler_is_guarded_and_only_boundaries_capture(): for group in groups: for handler in group["hooks"]: commands.append((event, handler["command"])) - assert len(commands) == 21 # - the three retired directive-recall + assert len(commands) == 22 # + PostToolUseFailure (rulebook_hook.py post — + # the failure lane, ENG-1236), + # + UserPromptExpansion (rulebook_hook.py + # expansion — a typed command noted, ENG-1235), + # - the three retired directive-recall # handlers (PreToolUse ×2, PostToolUse), - # + PreToolUse (add_memory_gate.py, and - # create_rule_origin.py — the session a rule - # is filed from), + # - the retired add_memory_gate.py (the + # server removed add_memory), + # + PreToolUse (create_rule_origin.py — the + # session a rule is filed from), # + UserPromptSubmit (brain_brief.py prompt, # and rulebook_hook.py prompt — the lane that # arms a prompt-armed obligation), diff --git a/tests/codex_rulebook_context_test.py b/tests/codex_rulebook_context_test.py index 058d5fb9..8f327611 100644 --- a/tests/codex_rulebook_context_test.py +++ b/tests/codex_rulebook_context_test.py @@ -50,10 +50,14 @@ def test_normalized_desktop_payload_and_explicit_context(): assert 'fixture rule fired' in output.getvalue(), output.getvalue() rows = [json.loads(x) for x in (base/'state/ledger/fires.jsonl').read_text().splitlines()] assert len(rows) == 1 and rows[0]['host'] == 'codex' - for cmd in ['echo x; '+command, 'cd '+shlex.quote(str(repo))+'; echo rulebook-probe', - 'cd "$TARGET" && echo rulebook-probe']: + # The call's repo is where its FIRST command runs: a command before the + # cd runs in the (non-repo) cwd, and an unset variable names nothing. + for cmd in ['echo x; '+command, 'cd "$TARGET_UNSET_RB" && echo rulebook-probe']: payload['tool_input']['command'] = cmd assert rb.repo_of_call(payload)[0] == '' + # A `cd X;` chain runs the next command in X, like `cd X &&`. + payload['tool_input']['command'] = 'cd '+shlex.quote(str(repo))+'; echo rulebook-probe' + assert rb.repo_of_call(payload)[1] == str(repo) # The raw exec_command shape remains supported. payload.update(tool_name='exec_command', tool_input={'cmd':'echo probe','workdir':str(repo)}) normalized=json.loads(bridge._rulebook_payload(json.dumps(payload).encode())) @@ -83,9 +87,7 @@ def test_bundled_route_defers_only_for_installed_matching_user_handler(): assert not bridge._user_bridge_handles('PreToolUse',b'{"tool_name":"Bash"}') -def test_the_package_selects_codex_hooks_and_gives_a_projectless_notice(): - # Was asserted against plugins/memhub-staging until the staging build moved - # to agent-plugins-internal; the shipped package makes the same promise. +def test_staging_selects_codex_hooks_and_projectless_notice(): manifest=json.loads((ROOT/'plugins/memhub/.codex-plugin/plugin.json').read_text()) assert manifest['hooks']=='./hooks/codex-hooks.json' assert manifest['name']=='memhub' diff --git a/tests/cursor_capture_test.py b/tests/cursor_capture_test.py index ff671c5a..e5205b5c 100644 --- a/tests/cursor_capture_test.py +++ b/tests/cursor_capture_test.py @@ -118,8 +118,13 @@ def test_cursor_manifest_uses_one_portable_launcher_per_event(): .read_text(encoding="utf-8")) assert set(document["hooks"]) == { "beforeShellExecution", "afterShellExecution", "afterFileEdit", - "afterAgentResponse", "stop", "beforeSubmitPrompt", "sessionEnd" + "afterAgentResponse", "stop", "beforeSubmitPrompt", "sessionEnd", + # The rulebook lane (cursor_rulebook.py). + "sessionStart", "preToolUse", "postToolUse", "beforeReadFile", } + # Only the Read/Task calls the rulebook lane tracks, not every tool call. + assert document["hooks"]["preToolUse"][0]["matcher"] == "Read|Task" + assert document["hooks"]["postToolUse"][0]["matcher"] == "Task" for event, handlers in document["hooks"].items(): assert len(handlers) == 1, event command = handlers[0]["command"] @@ -515,6 +520,64 @@ def _res(structured=None, texts=(), is_error=False): print("PASS test_import_verdicts_and_dormancy") +def test_live_store_falls_back_to_the_transcript(): + """A store that exists but cannot be READ must not win the source choice. + + cursor-agent holds its chat store open for the life of the session, so at + sessionEnd — the only hook that captures a CLI session — sqlite refuses the + read-only open the reader uses. locate() names the file regardless, so the + flusher used to pick "store", fail to open it, and skip: no cursor-agent + session ever reached MemHub, with a complete transcript sitting beside it. + """ + import sqlite3 + from readers import cursor as cursor_reader + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + uuid = "019c6e48-b66c-7881-9301-99c87fc66cf6" + store = root / "store.db" + con = sqlite3.connect(store) + con.execute("create table blobs (id text)") + con.commit() + con.close() + assert cursor_reader.readable(store) # closed: readable + transcript = root / uuid / f"{uuid}.jsonl" + transcript.parent.mkdir() + transcript.write_text("{}\n", encoding="utf-8") + + located, readable = [store, ""], [True] + original_locate = cursor_reader.locate + original_readable = cursor_reader.readable + original_valid = cursor_flush._valid_transcript_path + cursor_reader.locate = lambda ref: (located[0], located[1]) + cursor_reader.readable = lambda path: readable[0] + cursor_flush._valid_transcript_path = lambda raw, u, root=None: ( + (transcript, "") if raw else (None, "no transcript_path")) + try: + payload = {"transcript_path": str(transcript)} + # readable store: unchanged behavior, the richer source still wins + kind, path, err = cursor_flush._source_for(uuid, payload, {}) + assert (kind, path) == ("store", store), (kind, path, err) + # live store: the validated transcript is used instead of skipping + readable[0] = False + kind, path, err = cursor_flush._source_for(uuid, payload, {}) + assert (kind, path) == ("transcript", transcript), (kind, path, err) + # neither readable nor a transcript: still an error, never a guess + kind, path, err = cursor_flush._source_for(uuid, {}, {}) + assert kind is None and path is None and err + # a STICKY store stays sticky: switching sources mid-session would + # renumber every record uuid, which is why only the first choice + # consults readability. + kind, path, err = cursor_flush._source_for( + uuid, payload, {"source_kind": "store"}) + assert kind == "store" and path == store + finally: + cursor_reader.locate = original_locate + cursor_reader.readable = original_readable + cursor_flush._valid_transcript_path = original_valid + print("PASS test_live_store_falls_back_to_the_transcript") + + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_") and callable(fn): diff --git a/tests/documentation_test.py b/tests/documentation_test.py index 7ce5e8c7..959bb5cc 100644 --- a/tests/documentation_test.py +++ b/tests/documentation_test.py @@ -105,14 +105,14 @@ def test_the_finder_collects_facts_from_the_pr_it_was_given() -> None: """`gh pr view <n>` resolves the number against the CURRENT checkout, so a URL for another repo would have ranked sessions against a different pull request and then linked them to the one the user named (Codex, #182).""" - base = ROOT / "plugins" / "memhub" / "skills" / "find-contributing-sessions" + base = ROOT / "plugins" / "memhub" / "skills" / "link-pr" skill = (base / "SKILL.md").read_text(encoding="utf-8") scanner = (base / "scripts" / "find_sessions.py").read_text(encoding="utf-8") flat = " ".join(skill.split()) # Since ENG-1128 the scanner collects the facts itself (batch mode), so the # property moved into the script: step 1 still resolves a bare NUMBER to a # URL, and the facts are read from that URL. - facts = skill[skill.index("## 2. Collect the facts"):skill.index("## 3. One consolidated")] + facts = skill[skill.index("### 3.2 Collect the facts"):skill.index("### 3.3 One consolidated")] check("the skill hands the scanner URLs, not numbers", "--prs-from" in facts and "**by URL**" in facts) check("…and it says why", "resolves the number against the CURRENT checkout" in flat) @@ -124,16 +124,17 @@ def test_the_finder_collects_facts_from_the_pr_it_was_given() -> None: def test_the_finder_maps_my_unlinked_prs() -> None: """ENG-1128: no argument maps the caller's own unlinked PRs through the - backend's list tool, `--pr` names specific ones, and without a linked + backend's list tool, `--find <pr>...` names specific ones, and without a linked GitHub identity the user's own `gh` is the fallback.""" - skill = (ROOT / "plugins" / "memhub" / "skills" / "find-contributing-sessions" + skill = (ROOT / "plugins" / "memhub" / "skills" / "link-pr" / "SKILL.md").read_text(encoding="utf-8") flat = " ".join(skill.split()) - for text in ("list_my_unlinked_prs(limit=25)", "--pr <url-or-number>", + for text in ("list_my_unlinked_prs(limit=25)", "--find [pr-or-url ...]", "gh search prs --author=@me", "`github_identity_linked: false`", "`github_connected: false`", "legacy_branch_sessions", - "Link nothing without an explicit yes", "Never send `pr_type`"): - check(f"find-contributing-sessions says {text!r}", text in flat) + "Link nothing without an explicit yes", "Never send `pr_type` from `--find`", + 'link_source="session_found"'): + check(f"link-pr --find says {text!r}", text in flat) frontmatter = skill.split("---", 2)[1] for spelling in ("mcp__plugin_memhub_memhub__list_my_unlinked_prs", "mcp__plugin_memhub_memhub__list_my_unlinked_prs"): @@ -250,16 +251,21 @@ def test_create_rule_skill_keeps_its_authoring_gates() -> None: # cp/git/python3/rm can still fire on the parent's shell. "must be the sub-agent's rather than the parent's", # ENG-1195: one rulebook per scope. Step 0 decides who the - # rule applies to without asking: org-wide unless the person - # says "just me" or names a workspace, and the workspace - # scope is never offered unprompted. + # rule applies to without asking, and the workspace scope is + # never offered unprompted. ENG-1243: personal unless the + # person puts it on everyone or the agent can say in one + # sentence why anyone working in the repo or org needs it. "## 0. Who it applies to", - "**Otherwise use `org`.**", + "| `personal` (**the default**) |", + "**Otherwise use `personal`**", + "why\n the rule is right for **anyone**", + "with that one\nsentence", "never offer the workspace scope", "`scope` from step 0", - # the harness draft path files org-wide and never looks up - # or makes a book - 'always files with `scope: "org"`', + # the harness draft path files personal too, says its org + # reason in `why`, and never looks up or makes a book + 'a harness draft files with\n `scope: "personal"`', + "(`Org-wide because …`)", # the report names who the rule reaches, never a stored # book name "never a\nrulebook's stored name"): @@ -307,7 +313,7 @@ def part(text: str, start: str, end: str) -> str: "inherits what it does not name" in file_ and "re-state `when`" in file_) harness = (CREATE_RULE / "references" / "harness.md").read_text(encoding="utf-8") check("the harness draft's call carries `when`, `do`, `why`", - '`scope: "org"`, `title`, `statement`, `when`, `do`, `why`' in harness) + '`scope` (step 0 above), `title`, `statement`, `when`, `do`, `why`' in harness) check("…written without asking", "`when`, `do` and `why` are written here without asking" in harness) start = (skills / "start-rulebook" / "SKILL.md").read_text(encoding="utf-8") @@ -333,13 +339,14 @@ def part(text: str, start: str, end: str) -> str: # shipped file that still names a removed tool (or the old `memory_type` # parameter) teaches the agent a call the server refuses. ENG-1195 retired # `create_rulebook` (rulebooks are made per scope by the server; the backend -# keeps a deprecated shim for older plugins), so the plugin's surface is 33. +# keeps a deprecated shim for older plugins), so the plugin's surface is 33; +# the backend then removed `add_memory`, so it is 32. _MCP_SURFACE = { "list_agent_brains", "get_brain_overview", "search_memory", "read_memory", "get_artifact_lineage", "list_tags", "save_artifact", "ingest_document_from_url", "tag_memory", "copy_to_agent_brain", "delete_memory", "create_agent_brain", "share_agent_brain", "list_agent_brain_access", "list_brain_folders", - "move_brain_to_folder", "list_sessions", "add_memory", "import_conversation", + "move_brain_to_folder", "list_sessions", "import_conversation", "list_rulebooks", "list_rules", "create_rule", "link_pr", "unlink_pr", "list_my_unlinked_prs", "get_spec_workflow", "list_skills", "get_skill", "create_skill", "list_orgs", "list_workspaces", "create_workspace", @@ -354,8 +361,8 @@ def part(text: str, start: str, end: str) -> str: ) -def test_mcp_surface_is_33_tools() -> None: - check("the surface this test pins is 33 tools", len(_MCP_SURFACE) == 33) +def test_mcp_surface_is_32_tools() -> None: + check("the surface this test pins is 32 tools", len(_MCP_SURFACE) == 32) def test_shipped_files_name_no_removed_mcp_tool() -> None: diff --git a/tests/find_sessions_test.py b/tests/find_sessions_test.py index 8e5e4f10..aa339e82 100644 --- a/tests/find_sessions_test.py +++ b/tests/find_sessions_test.py @@ -25,7 +25,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -SCRIPT = (ROOT / "plugins" / "memhub" / "skills" / "find-contributing-sessions" +SCRIPT = (ROOT / "plugins" / "memhub" / "skills" / "link-pr" / "scripts" / "find_sessions.py") SCRIPTS = ROOT / "plugins" / "memhub" / "scripts" @@ -495,6 +495,390 @@ def test_git_add_and_commit_are_found_by_tokens_not_at_command_position(): find_sessions._git_path_arguments("git log --oneline") == []) +# --- batch mode (ENG-1128): many PRs, facts read with gh, one pass --------- +# +# A fake `gh` on PATH answers from a JSON file and logs every call, so the +# suite needs neither the real CLI nor the network. + +_FAKE_GH = '''\ +import json, os, sys +args = sys.argv[1:] +with open(os.environ["FAKE_GH_LOG"], "a", encoding="utf-8") as log: + log.write(json.dumps(args) + "\\n") +db = json.load(open(os.environ["FAKE_GH_DB"], encoding="utf-8")) +if args[:2] == ["pr", "view"]: + reply = db.get("view", {}).get(args[2]) +elif args[:1] == ["api"]: + reply = db.get("api", {}).get(args[1]) +else: + reply = None +if reply is None or reply == "FAIL": + sys.stderr.write("gh: secret-ish diagnostic that must not be echoed\\n") + sys.exit(1) +sys.stdout.write(reply if isinstance(reply, str) else json.dumps(reply)) +''' + + +def _pr_view(url: str, files: list[str], head: str, base: str = "main", + shas: list[str] = (), created: str = "2026-09-10T00:00:00Z", + title: str = "a pull request") -> dict: + return {"url": url, "title": title, "headRefName": head, "baseRefName": base, + "createdAt": created, "files": [{"path": f} for f in files], + "commits": [{"oid": s} for s in shas]} + + +def _fake_gh(td: str, db: dict) -> tuple[dict, Path]: + """PATH with a fake `gh` first, plus the env it reads; returns (env, log).""" + bin_dir = Path(td) / "bin" + bin_dir.mkdir(exist_ok=True) + gh = bin_dir / "gh" + gh.write_text(f"#!{sys.executable}\n" + _FAKE_GH, encoding="utf-8") + gh.chmod(0o755) + db_path = Path(td) / "gh-db.json" + db_path.write_text(json.dumps(db), encoding="utf-8") + log = Path(td) / "gh-log.jsonl" + log.write_text("", encoding="utf-8") + env = {"PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + "FAKE_GH_DB": str(db_path), "FAKE_GH_LOG": str(log)} + return env, log + + +def run_batch(home: Path, env_extra: dict, *args: str, + prs_file_lines: list[str] | None = None) -> tuple[int, str, str]: + extra: list[str] = [] + listing = None + if prs_file_lines is not None: + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, + encoding="utf-8") as handle: + handle.write("\n".join(prs_file_lines) + "\n") + listing = handle.name + extra = ["--prs-from", listing] + try: + proc = subprocess.run( + [sys.executable, str(SCRIPT), *extra, *args], + capture_output=True, text=True, encoding="utf-8", timeout=120, + env={**os.environ, "HOME": str(home), "USERPROFILE": str(home), + "PYTHONUTF8": "1", "MEMHUB_PLUGIN_SCRIPTS": str(SCRIPTS), + **env_extra}) + return proc.returncode, proc.stdout, proc.stderr + finally: + if listing: + os.unlink(listing) + + +def _gh_calls(log: Path) -> list[list[str]]: + return [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() + if line.strip()] + + +PR_A = "https://github.com/acme/repo/pull/7" +PR_B = "https://github.com/acme/repo/pull/8" + + +def test_batch_ranks_each_session_under_the_pr_it_wrote(): + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + wt = str(Path(td) / "checkouts" / "repo") + claude_session(home, "writer-a", wt, branch="feat/a", + edits=[f"{wt}/app/a1.py", f"{wt}/app/a2.py"]) + claude_session(home, "writer-b", wt, branch="feat/b", + edits=[f"{wt}/app/b1.py", f"{wt}/app/b2.py"]) + # Edited one file of each PR on neither branch: shows under BOTH. + claude_session(home, "both", wt, branch="scratch", + edits=[f"{wt}/app/a1.py", f"{wt}/app/b1.py", f"{wt}/app/shared.py"]) + env, log = _fake_gh(td, {"view": { + PR_A: _pr_view(PR_A, ["app/a1.py", "app/a2.py", "app/shared.py"], "feat/a", + title="feat: the a side"), + PR_B: _pr_view(PR_B, ["app/b1.py", "app/b2.py"], "feat/b")}}) + rc, out, err = run_batch(home, env, "--pr-url", PR_A, "--pr-url", PR_B, + "--host", "claude") + check("batch exits 0 with a JSON object", rc == 0 and out.strip().startswith("{"), + out + err) + doc = json.loads(out) if rc == 0 else {"prs": []} + by_url = {p["pr_url"]: p for p in doc["prs"]} + check("both PRs come back, in input order", + [p["pr_url"] for p in doc["prs"]] == [PR_A, PR_B], out[:300]) + a_ids = [c["conversation_id"] for c in by_url.get(PR_A, {}).get("candidates", [])] + b_ids = [c["conversation_id"] for c in by_url.get(PR_B, {}).get("candidates", [])] + check("PR A's writer leads PR A", a_ids[:1] == ["writer-a"], repr(a_ids)) + check("PR B's writer leads PR B", b_ids[:1] == ["writer-b"], repr(b_ids)) + check("PR A's writer is not a candidate for PR B", "writer-a" not in b_ids, + repr(b_ids)) + check("a session that touched both PRs is a candidate for both", + "both" in a_ids and "both" in b_ids, repr((a_ids, b_ids))) + check("the PR's own metadata rides along", + by_url.get(PR_A, {}).get("title") == "feat: the a side" + and by_url[PR_A]["repo"] == "acme/repo" and by_url[PR_A]["number"] == 7 + and by_url[PR_A]["head_ref"] == "feat/a", out[:400]) + check("candidate rows keep the single-mode shape", + by_url.get(PR_A, {}).get("candidates") + and set(by_url[PR_A]["candidates"][0]) == { + "conversation_id", "session_id", "host", "cwd", "mtime", "score", + "evidence"}, out[:400]) + check("no transcript prose in batch output", PROSE not in out) + check("sessions_scanned counts parsed sessions", doc.get("sessions_scanned") == 3, + repr(doc.get("sessions_scanned"))) + views = [c for c in _gh_calls(log) if c[:2] == ["pr", "view"]] + check("gh pr view is given the URL, never a bare number", + sorted(c[2] for c in views) == sorted([PR_A, PR_B]), repr(views)) + + +def test_batch_parses_each_transcript_once(): + """The point of batching: 25 PRs over 200 sessions must not read every + transcript 25 times.""" + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + wt = str(Path(td) / "repo") + for index in range(3): + claude_session(home, f"s{index}", wt, branch="feat/a", + edits=[f"{wt}/app/a1.py"]) + env, _log = _fake_gh(td, {"view": { + PR_A: _pr_view(PR_A, ["app/a1.py"], "feat/a"), + PR_B: _pr_view(PR_B, ["app/a1.py"], "feat/b")}}) + # A child process, not this one: the readers fix their store paths from + # $HOME at IMPORT, so only a process started with the fake HOME is + # guaranteed never to read the developer's real transcripts. + counter = ( + "import importlib.util, json, sys\n" + f"spec = importlib.util.spec_from_file_location('fs', {str(SCRIPT)!r})\n" + "fs = importlib.util.module_from_spec(spec); spec.loader.exec_module(fs)\n" + "reader = fs.readers.reader_for('claude'); original = reader.to_canonical\n" + "calls = []\n" + "def counting(path, *a, **k):\n" + " calls.append(str(path)); return original(path, *a, **k)\n" + "reader.to_canonical = counting\n" + "rc = fs.main(sys.argv[1:])\n" + "sys.stderr.write('CALLS=' + json.dumps(calls) + '\\n')\n" + "sys.exit(rc)\n") + proc = subprocess.run( + [sys.executable, "-c", counter, "--pr-url", PR_A, "--pr-url", PR_B, + "--host", "claude"], + capture_output=True, text=True, encoding="utf-8", timeout=120, + env={**os.environ, "HOME": str(home), "USERPROFILE": str(home), + "PYTHONUTF8": "1", "MEMHUB_PLUGIN_SCRIPTS": str(SCRIPTS), **env}) + line = [ln for ln in proc.stderr.splitlines() if ln.startswith("CALLS=")] + calls = json.loads(line[0][len("CALLS="):]) if line else [] + check("counted batch run exits 0", proc.returncode == 0, proc.stderr[-300:]) + check("every parse was inside the fake HOME", + calls and all(c.startswith(str(home)) for c in calls), repr(calls[:3])) + check("three sessions, two PRs → three parses, not six", + len(calls) == 3 and len(set(calls)) == 3, repr(calls)) + + +def test_batch_skips_a_bad_pr_and_keeps_going(): + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + wt = str(Path(td) / "repo") + claude_session(home, "writer-a", wt, branch="feat/a", edits=[f"{wt}/app/a1.py"]) + wrong = "https://github.com/acme/repo/pull/9" + failing = "https://github.com/acme/repo/pull/10" + empty = "https://github.com/acme/repo/pull/11" + env, _log = _fake_gh(td, {"view": { + PR_A: _pr_view(PR_A, ["app/a1.py"], "feat/a"), + # gh answered for a DIFFERENT pull request than the one asked for. + wrong: _pr_view("https://github.com/other/thing/pull/99", ["x.py"], "x"), + failing: "FAIL", + empty: _pr_view(empty, [], "feat/empty")}}) + rc, out, err = run_batch( + home, env, "--host", "claude", + prs_file_lines=["# a comment", "", PR_A, wrong, failing, empty, + "not a url", PR_A + "/", PR_A + "#discussion"]) + doc = json.loads(out) if rc == 0 else {"prs": [], "skipped_prs": []} + reasons = {s["pr_url"]: s["reason"] for s in doc["skipped_prs"]} + check("exit 0 although most PRs failed", rc == 0, err) + check("the good PR is still scanned", + [p["pr_url"] for p in doc["prs"]] == [PR_A], out[:300]) + check("a mismatched gh answer → url_mismatch", reasons.get(wrong) == "url_mismatch", + repr(reasons)) + check("a failing gh call → gh_failed", reasons.get(failing) == "gh_failed", + repr(reasons)) + check("a PR with no files → no_files", reasons.get(empty) == "no_files", + repr(reasons)) + check("an unparseable line → invalid_url", reasons.get("not a url") == "invalid_url", + repr(reasons)) + check("the same PR spelled three ways is scanned once", + len(doc["prs"]) == 1 and PR_A not in reasons, repr(doc["skipped_prs"])) + check("gh's own stderr is never echoed", "secret-ish" not in out + err, err) + + +def test_batch_url_spellings_and_redirects(): + """A PR tab (`/files`), `http://` and `www.github.com` all name the PR the + user meant; a renamed repo is the same PR under another name, reported as + such rather than lumped in with a gh answer for a different PR.""" + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + wt = str(Path(td) / "repo") + claude_session(home, "writer-a", wt, branch="feat/a", edits=[f"{wt}/app/a1.py"]) + renamed = "https://github.com/acme/old-name/pull/12" + env, log = _fake_gh(td, {"view": { + PR_A: _pr_view(PR_A, ["app/a1.py"], "feat/a", + title="fix: Ёжик Álvaro — non-ASCII survives"), + renamed: _pr_view("https://github.com/acme/new-name/pull/12", ["x.py"], "x")}}) + rc, out, err = run_batch( + home, env, "--host", "claude", + prs_file_lines=[PR_A + "/files", "http://github.com/acme/repo/pull/7", + "https://www.github.com/acme/repo/pull/7/commits/abc1234", + renamed]) + doc = json.loads(out) if rc == 0 else {"prs": [], "skipped_prs": []} + check("three spellings of PR A collapse to one scanned PR", + [p["pr_url"] for p in doc["prs"]] == [PR_A], out[:300] + err) + check("gh is asked about the normalised URL", + [c[2] for c in _gh_calls(log) if c[:2] == ["pr", "view"]][:1] == [PR_A], + repr(_gh_calls(log))) + check("a non-ASCII title comes through intact", + doc["prs"] and doc["prs"][0]["title"] == "fix: Ёжик Álvaro — non-ASCII survives", + out[:300]) + check("a renamed repo → url_redirected with the canonical URL", + {"pr_url": renamed, "reason": "url_redirected", + "canonical_url": "https://github.com/acme/new-name/pull/12"} + in doc["skipped_prs"], repr(doc["skipped_prs"])) + + +def test_batch_matches_single_mode_evidence(): + """Batch scoring reuses one feature pass per session; its evidence must be + exactly what single mode reports for the same PR.""" + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + wt = str(Path(td) / "repo") + sha = "abcdef1234567890abcdef1234567890abcdef12" + # The helper pairs every result with the FIRST call (`t1`), so the + # commit must be that call for its output to count as proof. + claude_session(home, "committer", wt, branch="feat/a", + commands=["git commit -m 'x' app/a1.py app/a2.py"], + results=[f"[feat/a {sha[:7]}] x"]) + env, _log = _fake_gh(td, {"view": { + PR_A: _pr_view(PR_A, ["app/a1.py", "app/a2.py"], "feat/a", shas=[sha], + created="2026-09-10T00:00:00Z")}}) + rc, out, err = run_batch(home, env, "--pr-url", PR_A, "--host", "claude") + batch = json.loads(out)["prs"][0]["candidates"] if rc == 0 else [] + rc2, out2, err2 = run(home, ["app/a1.py", "app/a2.py"], "--branch", "feat/a", + "--base", "main", "--sha", sha, + "--created-at", "2026-09-10T00:00:00Z", + "--repo", "repo", "--host", "claude") + single = json.loads(out2) if rc2 == 0 else None + check("batch and single mode agree row for row", batch and batch == single, + f"{batch!r}\n{single!r}") + check("…including the sha proof", batch and batch[0]["evidence"]["shas"] == [sha[:7]], + repr(batch)) + + +def test_batch_caps_the_number_of_prs(): + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + home.mkdir() + env, log = _fake_gh(td, {"view": { + PR_A: _pr_view(PR_A, ["a.py"], "feat/a"), + PR_B: _pr_view(PR_B, ["b.py"], "feat/b")}}) + rc, out, err = run_batch(home, env, "--pr-url", PR_A, "--pr-url", PR_B, + "--max-prs", "1", "--host", "claude") + doc = json.loads(out) if rc == 0 else {"prs": [], "skipped_prs": []} + check("only the first PR is scanned", + [p["pr_url"] for p in doc["prs"]] == [PR_A], out[:300]) + check("the second is reported over_cap", + doc["skipped_prs"] == [{"pr_url": PR_B, "reason": "over_cap"}], + repr(doc["skipped_prs"])) + check("…and gh was never asked about it", + all(PR_B not in c for c in _gh_calls(log)), repr(_gh_calls(log))) + + +def test_batch_pages_a_large_pr_s_files_through_the_api(): + """`gh pr view --json files` stops at 100. A PR past that must be read + through the REST API.""" + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + wt = str(Path(td) / "repo") + claude_session(home, "late-file", wt, branch="elsewhere", + edits=[f"{wt}/app/f150.py", f"{wt}/app/f151.py"]) + first_page = [f"app/f{i}.py" for i in range(100)] + everything = "\n".join(f"app/f{i}.py" for i in range(160)) + "\n" + env, log = _fake_gh(td, { + "view": {PR_A: _pr_view(PR_A, first_page, "feat/big")}, + "api": {"repos/acme/repo/pulls/7/files": everything}}) + rc, out, err = run_batch(home, env, "--pr-url", PR_A, "--host", "claude") + doc = json.loads(out) if rc == 0 else {"prs": []} + api = [c for c in _gh_calls(log) if c[:1] == ["api"]] + check("the files API was paged", len(api) == 1 and "--paginate" in api[0], + repr(api)) + cands = doc["prs"][0]["candidates"] if doc["prs"] else [] + check("a file past the first 100 still counts as evidence", + cands and "app/f150.py" in cands[0]["evidence"]["files"], out[:300]) + + +def test_batch_skips_an_enterprise_host(): + """MemHub's link_pr takes github.com PRs only, so an enterprise PR is + reported as unsupported_host — never scanned, never asked of gh, and it + does not use up the PR cap.""" + ghe = "https://ghe.corp/acme/repo/pull/7" + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + home.mkdir() + env, log = _fake_gh(td, {"view": { + ghe: _pr_view(ghe, ["a.py"], "feat/a"), + PR_A: _pr_view(PR_A, ["a.py"], "feat/a")}}) + rc, out, err = run_batch(home, env, "--pr-url", ghe, "--pr-url", ghe + "/files", + "--pr-url", PR_A, "--max-prs", "1", "--host", "claude") + doc = json.loads(out) if rc == 0 else {"prs": [], "skipped_prs": []} + check("the enterprise PR is skipped once as unsupported_host", + doc["skipped_prs"] == [{"pr_url": ghe, "reason": "unsupported_host"}], + repr(doc["skipped_prs"]) + err) + check("…the github.com PR still fits under the cap", + [p["pr_url"] for p in doc["prs"]] == [PR_A], out[:300]) + check("…and gh was never asked about it", + all(not any("ghe.corp" in a for a in c) for c in _gh_calls(log)), + repr(_gh_calls(log))) + + +def test_batch_repo_filter_is_per_pr(): + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + # No git remote in these directories, so the repo name falls back to + # the directory's basename — `repo` and `other-project`. + in_repo = str(Path(td) / "repo") + elsewhere = str(Path(td) / "other-project") + claude_session(home, "in-repo", in_repo, branch="x", edits=[f"{in_repo}/README.md"], + commands=["git switch -c feat/a"]) + claude_session(home, "elsewhere", elsewhere, branch="x", + edits=[f"{elsewhere}/README.md"], commands=["git switch -c feat/a"]) + # GitHub names are case-insensitive: a checkout spelled `REPO` is the PR's repo. + shouted = str(Path(td) / "upper" / "REPO") + claude_session(home, "shouted", shouted, branch="x", + edits=[f"{shouted}/README.md"], commands=["git switch -c feat/a"]) + env, _log = _fake_gh(td, {"view": {PR_A: _pr_view(PR_A, ["README.md"], "feat/a")}}) + rc, out, err = run_batch(home, env, "--pr-url", PR_A, "--host", "claude") + doc = json.loads(out) if rc == 0 else {"prs": [{"candidates": []}]} + ids = [c["conversation_id"] for c in doc["prs"][0]["candidates"]] + check("the session in the PR's repo stays", "in-repo" in ids, repr(ids)) + check("…and so does one whose checkout spells it in another case", + "shouted" in ids, repr(ids)) + check("an unrelated project's README.md does not", "elsewhere" not in ids, repr(ids)) + + +def test_batch_argument_errors_are_clean(): + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + home.mkdir() + env, _log = _fake_gh(td, {"view": {}}) + listing = Path(td) / "files.txt" + listing.write_text("a.py\n", encoding="utf-8") + for label, args in ( + ("both modes", ["--files-from", str(listing), "--pr-url", PR_A]), + ("neither mode", []), + ("a single-mode flag in a batch", ["--pr-url", PR_A, "--branch", "x"]), + ("--repo in a batch", ["--pr-url", PR_A, "--repo", "repo"]), + ("--max-prs 0", ["--pr-url", PR_A, "--max-prs", "0"])): + rc, _out, err = run_batch(home, env, *args) + check(f"{label} → exit 2, no traceback", + rc == 2 and "ERROR" in err and "Traceback" not in err, err) + # gh absent: PATH holds only python's own directory. + bare = {"PATH": str(Path(sys.executable).parent)} + if (Path(sys.executable).parent / "gh").exists(): + print(" skip gh-absent check: gh sits next to python") + else: + rc, _out, err = run_batch(home, bare, "--pr-url", PR_A) + check("gh not on PATH → exit 2 naming gh", + rc == 2 and "gh not found" in err and "Traceback" not in err, err) + + if __name__ == "__main__": print("find_sessions") for name, fn in sorted(globals().items()): diff --git a/tests/harness_stop_test.py b/tests/harness_stop_test.py index 7199f21b..4c50cef1 100644 --- a/tests/harness_stop_test.py +++ b/tests/harness_stop_test.py @@ -105,7 +105,7 @@ def _classifier(reply): """Substitute the one server call; yields the windows it was sent.""" sent = [] real = hx.server_classify - hx.server_classify = lambda window, repo="", timeout=0: (sent.append(window), (reply, 0.1))[1] + hx.server_classify = lambda window, repo="", timeout=0, **_kw: (sent.append(window), (reply, 0.1))[1] try: yield sent finally: @@ -367,6 +367,73 @@ def test_the_handoff_command_prints_the_fork_instruction(): print("PASS test_the_handoff_command_prints_the_fork_instruction") +def test_a_cost_verdict_hands_a_repair_once_and_handoff_prints_the_repair_prompt(): + """Live repair (harness-self-improvement-spec §4). The flush recorded a + `cost` verdict for one of this session's fires; the next Stop says one + line for the person naming the rule and the turn the fire happened on, + and the `handoff --repair` command prints a fork prompt that repairs that + rule as a proposed supersede. The same cost is never handed twice, and a + verdict below the bar hands nothing.""" + import fire_effect as fe + import rulebook_hook as rh + with _Env() as env: + tp = env.base / "sess.jsonl" + _transcript(tp, [("tidy the notes", "done", [("Edit", {"file_path": "n.md"}, "ok", False)]), + ("thanks", "ok", []), + ("next", "ok", [])]) + ld = rh._ledger_dir() + os.makedirs(ld, exist_ok=True) + with open(os.path.join(ld, "fires.jsonl"), "a", encoding="utf-8") as f: + f.write(json.dumps({"fire_id": "f-cost", "session_id": "sess", "rule_id": "R-1", + "title": "Resolve bot review threads", "tool_use_id": "t1-0", + "mode": "advise", "hook_phase": "pre", + "fired_at": "2026-10-13T10:00:00+00:00"}) + "\n") + f.write(json.dumps({"fire_id": "f-low", "session_id": "sess", "rule_id": "R-2", + "title": "Low cost", "tool_use_id": "t1-0", "mode": "advise", + "hook_phase": "pre", "fired_at": "2026-10-13T10:00:01+00:00"}) + "\n") + fe.record_verdicts(ld, [{"fire_id": "f-low", "effect": "cost", "effect_helped": 0.2, "effect_cost": 0.5}]) + with _classifier({"signal": False, "reason": "quiet"}): + out = _stop(env, tp) + assert out is None, f"a cost below the bar hands nothing: {out}" + fe.record_verdicts(ld, [{"fire_id": "f-cost", "effect": "cost", "effect_helped": 0.1, "effect_cost": 0.9}]) + with _classifier({"signal": False, "reason": "quiet"}): + out = _stop(env, tp) + ctx = (out or {}).get("hookSpecificOutput", {}).get("additionalContext", "") + assert ctx.startswith(hs.REPAIR_PREFIX) and "Resolve bot review threads" in ctx and "at turn 1" in ctx, ctx + assert hs.FORK_MARK not in ctx, "the fork prompt never reaches the person's screen" + log = hx.log_path("stop.log").read_text() + assert "repair sess t1: fork requested for rule R-1" in log, log + with _classifier({"signal": False, "reason": "quiet"}): + again = _stop(env, tp) + assert again is None, f"the same cost is handed once: {again}" + run = lambda *args: subprocess.run( # noqa: E731 + [sys.executable, str(SCRIPTS / "harness_stop.py"), "handoff", *args], + capture_output=True, text=True, timeout=30, + env=dict(os.environ, MEMHUB_HARNESS_EXTRACT="0", CLAUDE_CONFIG_DIR=str(env.base / "claude"))) + proj = env.base / "claude" / "projects" / "-w" + proj.mkdir(parents=True) + shutil.copy(tp, proj / "sess.jsonl") + ok = run("--moment", "sess#1", "--repair") + assert ok.returncode == 0, ok.stderr + reason = ok.stdout + assert reason.startswith(hs.BLOCK_PREFIX) and "a rule cost the work at turn 1" in reason, reason + assert f"{hs.FORK_MARK}, moment sess#1" in reason and "to REPAIR one team rule" in reason, reason + assert 'Rule "Resolve bot review threads" (rule_id R-1)' in reason and "p_cost 0.90" in reason, reason + assert "Harness-repair path" in reason and "supersedes_rule_id" in reason, reason + assert f'stamp --transcript "{proj / "sess.jsonl"}" --turn 1' in reason, reason + assert f'"{hs.FORK_MARK}: repaired <title>"' in reason and 'says "filed" or "repaired"' in reason, reason + bad = run("--moment", "sess#2", "--repair") + assert bad.returncode == 2 and "no repair noted" in bad.stderr, bad + print("PASS test_a_cost_verdict_hands_a_repair_once_and_handoff_prints_the_repair_prompt") + + +def test_the_standing_rule_names_the_repair_line_and_its_handoff(): + rule = hs.session_rule("sess") + assert hs.REPAIR_PREFIX in rule and "handoff --moment sess#N --repair" in rule, rule + assert "files or repairs a rule" in rule, rule + print("PASS test_the_standing_rule_names_the_repair_line_and_its_handoff") + + def test_session_start_carries_the_standing_rule(): """The rule that keeps the agent from narrating a launch goes out at SessionStart, a channel the person is not shown; with the flag off, or for diff --git a/tests/mine_sessions_test.py b/tests/mine_sessions_test.py index 320194fd..f83a33dd 100644 --- a/tests/mine_sessions_test.py +++ b/tests/mine_sessions_test.py @@ -10,6 +10,7 @@ sys.path.insert(0, str(ROOT / "plugins" / "memhub" / "scripts")) import rulebook_paths # noqa: E402 +import rulebook_hook # noqa: E402 def _book_dir(home) -> Path: @@ -18,8 +19,9 @@ def _book_dir(home) -> Path: return Path(home) / ".config" / "memhub-plugin" / "rulebook" / rulebook_paths.backend_key() / "book" -def _run(*extra, home): - env = {k: v for k, v in os.environ.items() if k not in ("CLAUDE_PLUGIN_ROOT", "MEMHUB_PLUGIN_SCRIPTS")} +def _run(*extra, home, **more_env): + env = {k: v for k, v in os.environ.items() if k not in ("CLAUDE_PLUGIN_ROOT", "MEMHUB_PLUGIN_SCRIPTS", "CLAUDE_CONFIG_DIR")} + env.update(more_env) env["HOME"] = home env["USERPROFILE"] = home return subprocess.run([sys.executable, str(SCRIPT), *extra], capture_output=True, text=True, env=env, cwd=home, timeout=120) @@ -54,9 +56,9 @@ def main() -> int: {"title": "empty-origin-probe", "matcher": {"event": "bash", "command_rx": "never-happens-abc\\b"}, "claude_md": {}}, "not-a-dict"])) book = _book_dir(home); book.mkdir(parents=True) - (book / "x.json").write_text(json.dumps({"rules": [{"delivery": "agent_hook", "matcher": {"event": "bash", "command_rx": "x"}}, # no title: must be skipped, not fatal + (book / os.path.basename(rulebook_hook.book_path("probe-repo"))).write_text(json.dumps({"rules": [{"delivery": "agent_hook", "matcher": {"event": "bash", "command_rx": "x"}}, # no title: must be skipped, not fatal {"title": "ok-rule", "rule_id": "r1", "delivery": "agent_hook", "mode": "advise", "version": "not-a-number", "matcher": {"event": "bash", "command_rx": "git\\s+push\\b", "warn_once_per": "session"}, "scope_repos": [], "scope_paths": [], "scope_exclude_paths": []}]})) - p = _run("--out", str(out), "--rule-file", str(cand), "--rule-file", str(partial), "--rule-file", str(anchor), "--rule-file", str(sess_ord), "--rule-file", str(prompt_ord), "--rule-file", str(bare_prompt), "--candidates", str(cands), "--claude-md", str(md), "--facets", str(facets), home=home) + p = _run("--out", str(out), "--repo", "probe-repo", "--rule-file", str(cand), "--rule-file", str(partial), "--rule-file", str(anchor), "--rule-file", str(sess_ord), "--rule-file", str(prompt_ord), "--rule-file", str(bare_prompt), "--candidates", str(cands), "--claude-md", str(md), "--facets", str(facets), home=home) ok = p.returncode == 0 and "sessions read" in p.stdout and "probe-rule" in p.stdout and "WHAT CLAUDE.MD DECLARES" in p.stdout and "PROPOSED RULES" in p.stdout and "unknown friction category ['bogus_label']" in p.stderr and "=== WHAT WENT WRONG" not in p.stdout and (out / "digests").is_dir() print(("ok " if ok else "FAIL"), "empty HOME: runs, backtests --rule-file, seeds from --claude-md, warns on --facets vocab but reports no facet for a session not in the corpus, writes digests/"); fails += not ok if not ok: print(p.stdout[-800:], p.stderr[-800:]) @@ -235,5 +237,95 @@ def _offered(): return sorted(os.path.basename(x) for batch in json.load(open(ou if not ok: print(stamps, _offered()) return 1 if fails else 0 + +def _rec(kind, content, cwd, ts, branch="fm-feat/probe-only-branch"): + return json.dumps({"type": kind, "cwd": cwd, "gitBranch": branch, "timestamp": ts, "message": {"content": content}}) + + +def _use(i, name, inp): + return {"type": "tool_use", "id": f"tu{i}", "name": name, "input": inp} + + +def counting() -> int: + """What a session counts as: its subagents' calls, a repo even after its worktree is deleted, an edit read the + way the hook reads it, one facet per session, and only the books of the repos the sessions are in.""" + fails = 0 + with tempfile.TemporaryDirectory() as home: + repo = Path(home) / "src" / "probe-repo"; repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "remote", "add", "origin", "https://example.com/acme/probe-repo.git"], check=True) + gone = repo / ".claude" / "worktrees" / "deleted-one" # never created: a worktree removed after the session + proj = Path(home) / ".claude" / "projects" / "-probe"; proj.mkdir(parents=True) + sid = "cccccccc-1111-2222-3333-444444444444" + (proj / f"{sid}.jsonl").write_text("\n".join([ + _rec("user", "fix the parser", str(gone), "2026-10-10T10:00:00Z"), + _rec("assistant", [_use(1, "Edit", {"file_path": "/x/a.py", "old_string": "# TODO keep\n", "new_string": "# TODO keep\nx = 1\n"})], str(gone), "2026-10-10T10:01:00Z"), + _rec("assistant", [_use(2, "MultiEdit", {"file_path": "/x/b.py", "edits": [{"old_string": "a", "new_string": "print('dbg')"}]})], str(gone), "2026-10-10T10:02:00Z"), + _rec("assistant", [_use(3, "Edit", {"file_path": "/x/c.py", "old_string": "y", "new_string": "import os\n"})], str(gone), "2026-10-10T10:03:00Z"), + ])) + sub = proj / sid / "subagents"; sub.mkdir(parents=True) + (sub / "agent-a1.jsonl").write_text("\n".join([ + _rec("user", "no, push it now", str(gone), "2026-10-10T10:01:30Z"), # the parent's prompt: never a correction by the person + _rec("assistant", [_use(9, "Bash", {"command": "git push origin HEAD"})], str(gone), "2026-10-10T10:01:40Z"), + ])) + rules = { + "push-probe": {"title": "push-probe", "matcher": {"event": "bash", "command_rx": "git\\s+push\\b"}}, + "todo-probe": {"title": "todo-probe", "matcher": {"event": "edit", "content_rx": "TODO"}}, + "print-probe": {"title": "print-probe", "matcher": {"event": "edit", "content_rx": "print\\("}}, + } + files = [] + for name, body in rules.items(): + f = Path(home) / f"{name}.json"; f.write_text(json.dumps(body)); files += ["--rule-file", str(f)] + book = _book_dir(home); book.mkdir(parents=True) + (book / os.path.basename(rulebook_hook.book_path("probe-repo"))).write_text(json.dumps({"rules": [ + {"title": "fresh-rule", "rule_id": "r1", "delivery": "agent_hook", "mode": "advise", "version": 1, "matcher": {"event": "bash", "command_rx": "git\\s+push\\b"}}]})) + (book / os.path.basename(rulebook_hook.book_path("some-fixture"))).write_text(json.dumps({"rules": [ + {"title": "stale-fixture-rule", "rule_id": "r9", "delivery": "agent_hook", "mode": "advise", "version": 1, "matcher": {"event": "bash", "command_rx": "git\\s+push\\b"}}]})) + out = Path(home) / "out" + p = _run("--out", str(out), "--repo", str(repo), "--digest-top", "0", *files, home=home) + rows = {r["title"]: r for r in json.load(open(out / "proposals.json")) if isinstance(r, dict) and r.get("title")} if (out / "proposals.json").exists() else {} + fired = {k: (rows.get(k) or {}).get("fired_n") for k in rules} + ok = p.returncode == 0 and "--repo " + str(repo) + " -> probe-repo" in p.stdout and "(M=1)" in p.stdout and "parent 1" in p.stdout + print(("ok " if ok else "FAIL"), "--repo given as a path names its repo, and a session whose worktree was deleted is placed by the repo it sat in"); fails += not ok + if not ok: print(p.stdout[:800], p.stderr[-600:]) + ok = fired["push-probe"] == 1 and "1 subagent transcripts folded" in p.stdout + print(("ok " if ok else "FAIL"), "a command run inside a subagent counts for its session"); fails += not ok + ok = fired["todo-probe"] == 0 and fired["print-probe"] == 1 + print(("ok " if ok else "FAIL"), "an edit is read as the hook reads it: a line it kept does not fire, a MultiEdit's edits do"); fails += not ok + if not ok: print(fired) + ok = "fresh-rule" in p.stdout and "stale-fixture-rule" not in p.stdout + print(("ok " if ok else "FAIL"), "rules already on come from this repo's book, not every book in the cache"); fails += not ok + corpus = json.load(open(out / "corpus.json")) if (out / "corpus.json").exists() else [{}] + ok = corpus[0].get("users") == ["fix the parser"] and corpus[0].get("stamp") == "1u3c0r" and "main_calls" not in corpus[0] + print(("ok " if ok else "FAIL"), "a subagent's prompt is not the person's turn, and the facet stamp counts the session's own calls"); fails += not ok + if not ok: print({k: corpus[0].get(k) for k in ("users", "stamp")}) + + # one facet per session: yours wins over an /insights facet for the same session + facets = Path(home) / "facets.json" + facets.write_text(json.dumps([{"session_id": sid, "outcome": "mostly", "friction": [{"category": "wrong_source", "detail": "mine"}]}])) + ins = Path(home) / ".claude" / "usage-data" / "facets"; ins.mkdir(parents=True) + (ins / f"{sid}.json").write_text(json.dumps({"session_id": sid, "friction_counts": {"buggy_code": 2}, "friction_detail": "insights"})) + p = _run("--out", str(out), "--repo", "probe-repo", "--digest-top", "0", "--facets", str(facets), home=home) + ok = p.returncode == 0 and "1 /insights facets left out" in p.stdout and "buggy_code" not in p.stdout and "wrong_source" in p.stdout + print(("ok " if ok else "FAIL"), "an /insights facet for a session your facets already read is left out, not counted twice"); fails += not ok + if not ok: print(p.stdout[-800:]) + + # Claude Code's own config dir, when set, is where its transcripts are + moved = Path(home) / "cfg"; shutil.move(str(Path(home) / ".claude"), str(moved)) + p = _run("--out", str(out), "--repo", "probe-repo", "--digest-top", "0", home=home, CLAUDE_CONFIG_DIR=str(moved)) + ok = p.returncode == 0 and "(M=1)" in p.stdout + print(("ok " if ok else "FAIL"), "transcripts are read from CLAUDE_CONFIG_DIR when it is set"); fails += not ok + if not ok: print(p.stdout[:400]) + shutil.move(str(moved), str(Path(home) / ".claude")) + + # the repo is remembered: once even the parent is gone, the cache still places the session + shutil.rmtree(repo) + p = _run("--out", str(out), "--repo", "probe-repo", "--digest-top", "0", home=home) + ok = p.returncode == 0 and "(M=1)" in p.stdout + print(("ok " if ok else "FAIL"), "a repo resolved once is kept, so the session stays placed after its whole checkout is gone"); fails += not ok + if not ok: print(p.stdout[:600]) + return fails + + if __name__ == "__main__": - sys.exit(main()) + sys.exit(1 if (main() + counting()) else 0) diff --git a/tests/pr_link_test.py b/tests/pr_link_test.py index 9b270bc1..80b9b3f1 100644 --- a/tests/pr_link_test.py +++ b/tests/pr_link_test.py @@ -35,6 +35,7 @@ sys.path.insert(0, str(SCRIPTS)) import mcp_http # noqa: E402 import pr_babysit_trigger # noqa: E402 +from _memhub_auth import skill_command # noqa: E402 import pr_link # noqa: E402 failures: list[str] = [] @@ -465,7 +466,7 @@ def test_github_mcp_tools_are_recognised_by_their_server_segment(): # …but a server whose NAME contains underscores is still GitHub. # `[^_]*` stopped at the first one and rejected every tool from # `github_enterprise`, or from any plugin-provided server — this repo's - # own tools arrive as `mcp__plugin_memhub-staging_memhub__…` + # own tools arrive as `mcp__plugin_memhub_memhub__…` # (Codex review, PR #182). ("mcp__github_enterprise__create_pull_request", True, True), ("mcp__plugin_github_github__create_pull_request", True, True), @@ -1131,7 +1132,12 @@ def test_the_contexts_say_the_right_thing_for_each_case(): check("B2 ends with the already-linked escape that keeps a babysit loop quiet", in_play.rstrip().endswith("say nothing at all."), in_play) check("B2 offers the finder and forbids running it unasked", - "/memhub:find-contributing-sessions" in in_play + # NOTE: public hardcodes the production prefix here and pins the + # rendered bytes in pr_work_type_test's GOLDEN_PRE_CLASSIFICATION, so + # W1.2's "skill prefix from the manifest" is NOT applied to this + # string. On a staging install this tells the user to run a command + # that does not exist there. Tracked as a W1.2 follow-up. + f"/memhub:link-pr --find {url}" in in_play and "without the user saying yes" in in_play) disconnected = pr_link.context_for( diff --git a/tests/pr_work_type_test.py b/tests/pr_work_type_test.py index 7d6c3cec..044bebf9 100644 --- a/tests/pr_work_type_test.py +++ b/tests/pr_work_type_test.py @@ -35,8 +35,11 @@ # classification sentence. A pull request that already carries a type must # render exactly this again — the ask disappears rather than degrading into # something new. Change these deliberately, never to make the suite green. -GOLDEN_PRE_CLASSIFICATION = {"CREATED": "0feb75e89d6c385f", - "IN_PLAY": "c79dc68b09546397"} +# Re-pinned once, deliberately: the finder the templates offer is now spelled +# `/memhub:link-pr --find {pr_url}` (the separate finder skill was merged into +# link-pr); no other byte changed. +GOLDEN_PRE_CLASSIFICATION = {"CREATED": "8ee3f30a78f924a2", + "IN_PLAY": "8711d86ee07c070c"} failures: list[str] = [] diff --git a/tests/rulebook_hook_test.py b/tests/rulebook_hook_test.py index 3a72f38f..92fb673c 100644 --- a/tests/rulebook_hook_test.py +++ b/tests/rulebook_hook_test.py @@ -104,6 +104,9 @@ def portability_check() -> None: os.mkdir(incomplete) incomplete_hook = os.path.join(incomplete, "rulebook_hook.py") shutil.copy2(HOOK, incomplete_hook) + # The lock shim is what is missing here; `rulebook_shell` is part of the + # hook itself (split out for the release tree's per-file size limit). + shutil.copy2(os.path.join(os.path.dirname(HOOK), "rulebook_shell.py"), incomplete) incomplete_repo = os.path.join(td, "incomplete-repo") os.makedirs(os.path.join(incomplete_repo, ".git")) with open( @@ -692,9 +695,7 @@ def bash(cmd, **kw): denied, c, _, _ = bash(cmd) check(f"read via bash: silent — {cmd.replace(repo, '<repo>')[:50]}", not denied and "[big-read]" not in c, c) - # the measured shape: a relative path after a cd (the session cwd stays the - # trust boundary — a cd OUT of the session's repo is not followed, as for - # every other lane) + # the measured shape: a relative path after a cd for cmd in ("cd src && cat big.py", f"cd {repo}/src && cat big.py", "cd src; cat ./big.py"): denied, c, _, _ = bash(cmd) check(f"read via bash: a relative path resolves against the cd — {cmd[:40]}", @@ -775,7 +776,9 @@ def replace_file(path, old, new): f.write(text.replace(old, new)) def bash(command, *, session="b1", run_it=True, pre=True, resp=None, mutate=None): - """pre → apply the command's file effect → post, the way a session does. + """pre → apply the command's file effect → post, the way a session does; + the advice of both lanes (a write the command line shows fires in the + pre lane, the disk scan in the post lane catches the rest). The hook receives the real shell syntax under test. Applying its expected file effect in Python keeps this fixture independent of @@ -785,8 +788,9 @@ def bash(command, *, session="b1", run_it=True, pre=True, resp=None, mutate=None tid = f"tu{n[0]}" ev = {"cwd": repo, "session_id": session, "tool_name": "Bash", "tool_use_id": tid, "tool_input": {"command": command}} + said = "" if pre: - run("pre", ev, env) + said = ctx(run("pre", ev, env)[1]) if run_it: if mutate is not None: mutate() @@ -794,7 +798,8 @@ def bash(command, *, session="b1", run_it=True, pre=True, resp=None, mutate=None else: r = subprocess.run(command, shell=True, cwd=repo, capture_output=True, text=True) resp = resp or {"stdout": r.stdout, "stderr": r.stderr, "exit_code": r.returncode} - return ctx(run("post", dict(ev, tool_response=resp or {"stdout": "", "exit_code": 0}), env)[1]) + return (said + "\n" + ctx(run("post", dict(ev, tool_response=resp or {"stdout": "", "exit_code": 0}), + env)[1])).strip() mig = "alembic/versions/20260902_user_logins.py" c = bash(f"cat > {mig} <<'EOF'\ndef upgrade():\n op.create_table('user_logins')\nEOF", @@ -1071,6 +1076,66 @@ def lines(command): H.command_root(td, "cd /nope/nowhere && gh pr create") == "") +def spec_pending_probe_checks() -> None: + """The post lane takes the branch diff (~20 git calls) only to answer a + pending `spec_untouched` entry for this checkout; with none it must not + run git for it, and with one the conversion still lands.""" + if shutil.which("git") is None: + check("spec pending probe (git unavailable here — skipped)", True) + return + with tempfile.TemporaryDirectory() as td: + td = os.path.realpath(td) + repo, base, shim = os.path.join(td, "repo"), os.path.join(td, "base"), os.path.join(td, "bin") + os.makedirs(repo) + os.makedirs(shim) + _git(repo, "init", "-q", "-b", "main") + with open(os.path.join(repo, "README.md"), "w") as f: + f.write("r\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "init") + _git(repo, "checkout", "-qb", "feature") + os.makedirs(os.path.join(repo, "docs", "specs")) + spec = "docs/specs/a.md" + with open(os.path.join(repo, spec), "w") as f: + f.write("---\nspec: a\nowns:\n - app/x.py\n---\n# A\n") + seed_book(base, "repo", [_row("r1", {"event": "read", "path_rx": r"nothing-matches-this"})]) + log = os.path.join(td, "git.log") + real_git = shutil.which("git") + with open(os.path.join(shim, "git"), "w") as f: + f.write(f'#!/bin/sh\necho "$@" >> "{log}"\nexec "{real_git}" "$@"\n') + os.chmod(os.path.join(shim, "git"), 0o755) + env = {"MEMHUB_RULEBOOK_BASE": base, "MEMHUB_RULEBOOK_FETCH": "0", + "MEMHUB_RULEBOOK_RECALL": "0", "MEMHUB_RULEBOOK_JUDGE": "0", + "PATH": shim + os.pathsep + os.environ.get("PATH", "")} + + def post(session): + open(log, "w").close() + run("post", {"session_id": session, "cwd": repo, "hook_event_name": "PostToolUse", + "tool_name": "Read", "tool_input": {"file_path": os.path.join(repo, "README.md")}, + "tool_response": {"type": "text"}, "tool_use_id": "t1"}, env) + with open(log) as f: + return f.read() + + calls = post("none-pending") + check("spec pending probe: no pending entry, no branch-diff git calls", + "merge-base" not in calls and "diff --name-only" not in calls, calls) + + os.makedirs(os.path.join(base, "state"), exist_ok=True) + with open(os.path.join(base, "state", "one-pending.json"), "w") as f: + json.dump({"spec_pending": {json.dumps(["r1", repo, "feature"]): { + "root": repo, "branch": "feature", "spec_dir": "docs/specs", "paths": [spec]}}}, f) + calls = post("one-pending") + check("spec pending probe: a pending entry for this checkout takes the branch diff", + "merge-base" in calls and "diff --name-only" in calls, calls) + events = [] + ev_path = os.path.join(base, "ledger", "events.jsonl") + if os.path.exists(ev_path): + with open(ev_path, encoding="utf-8") as f: + events = [json.loads(line) for line in f if line.strip()] + check("spec pending probe: the pending entry still converts", + any(e.get("kind") == "converted" and e.get("rule_id") == "r1" for e in events), str(events)) + + def apply_patch_checks() -> None: """Codex edits with `apply_patch`: the whole patch in `tool_input.command`, no `file_path`, a path absolute or relative to cwd. Payloads are the shape @@ -1143,8 +1208,215 @@ def pre(ev): "[tests-before-push]" in c, c) +# ── ENG-1201: one rulebook state dir per backend ───────────────────────── + +def _install(root, url): + """A copy of the plugin's scripts as one install: `<root>/scripts/…` beside + a `.mcp.json` naming `url` — all that tells a staging install from the + prod one promote_export.py makes.""" + shutil.copytree(os.path.dirname(os.path.abspath(HOOK)), os.path.join(root, "scripts"), + ignore=shutil.ignore_patterns("__pycache__")) + if url is not None: + with open(os.path.join(root, ".mcp.json"), "w", encoding="utf-8") as f: + json.dump({"mcpServers": {"memhub": {"type": "http", "url": url}}}, f) + return os.path.join(root, "scripts", "rulebook_hook.py") + + +def _paths_cli(hook): + """The install's `rulebook_paths.py`, which prints its base when run.""" + return os.path.join(os.path.dirname(hook), "rulebook_paths.py") + + +def _hook_at(hook, home, args, payload=None, **env_extra): + """Run one install's hook with HOME = `home` and none of the developer's + MEMHUB_* overrides, so the default (keyed) base is what is exercised and + the real ~/.config is never reached.""" + env = {k: v for k, v in os.environ.items() + if not k.startswith("MEMHUB_") and k != "CLAUDE_PLUGIN_ROOT"} + env.update(HOME=home, MEMHUB_RULEBOOK_FETCH="0", **env_extra) + argv = [_paths_cli(hook)] if args == ["base"] else [hook, *args] + p = subprocess.run([sys.executable, *argv], input=json.dumps(payload or {}), + capture_output=True, text=True, env=env, timeout=30) + return p.returncode, p.stdout.strip() + + +def env_keyed_base_checks() -> None: + """Two installs on one machine (staging + the prod export) keep separate + books, ledgers and session state — observed 2026-10-05: prod's refresh + replaced the staging book (89 rules) with its own (1 rule) mid-session, + because both lived at <base>/book/<repo>-<hash>.json. And the pre-keying + ledger is still drained, never dropped.""" + with tempfile.TemporaryDirectory() as td: + home = os.path.join(td, "home") + os.makedirs(home) + staging = _install(os.path.join(td, "staging"), "https://api.staging.example.test/mcp-server/mcp") + prod = _install(os.path.join(td, "prod"), "https://api.example.test/mcp-server/mcp") + broken = _install(os.path.join(td, "broken"), None) + shared = os.path.join(home, ".config", "memhub-plugin", "rulebook") + + _, s_base = _hook_at(staging, home, ["base"]) + _, p_base = _hook_at(prod, home, ["base"]) + _, s_book = _hook_at(staging, home, ["book-path", "xmem"]) + _, p_book = _hook_at(prod, home, ["book-path", "xmem"]) + check("env base: each install keys its state by its own backend host", + s_base == os.path.join(shared, "api.staging.example.test") + and p_base == os.path.join(shared, "api.example.test"), f"{s_base} | {p_base}") + check("env base: two installs never share a repo's book", + s_book != p_book and s_book.startswith(s_base + os.sep) + and p_book.startswith(p_base + os.sep), f"{s_book} | {p_book}") + _, b_base = _hook_at(broken, home, ["base"]) + check("env base: an install whose .mcp.json is unreadable keeps the old unkeyed base", + b_base == shared, b_base) + override = os.path.join(td, "override") + _, o_base = _hook_at(staging, home, ["base"], MEMHUB_RULEBOOK_BASE=override) + _, o_book = _hook_at(prod, home, ["book-path", "xmem"], MEMHUB_RULEBOOK_BASE=override) + check("env base: MEMHUB_RULEBOOK_BASE still names the base verbatim, unkeyed", + o_base == override and o_book.startswith(os.path.join(override, "book") + os.sep), + f"{o_base} | {o_book}") + _, x_base = _hook_at(staging, home, ["base"], MEMHUB_MCP_BASE_URL="http://127.0.0.1:9") + check("env base: a per-process MEMHUB_MCP_BASE_URL (a test proxy) does not re-key the install", + x_base == s_base, x_base) + + # The live failure, end to end: the staging book gates, the prod book + # (cached for the same repo) has nothing — each install reads its own, + # and the staging fire lands in the staging ledger only. + repo = os.path.join(td, "xmem") + os.makedirs(os.path.join(repo, ".git")) + with open(os.path.join(repo, ".git", "HEAD"), "w", encoding="utf-8") as f: + f.write("ref: refs/heads/main\n") + gate = {"id": "staging-gate", "on": "bash", "rx": r"git\s+push", "mode": "gate", + "fire_scope": "call", "repo_scope": "any", "status": "active", + "text": "Do not push yet", "why": "staging rule"} + seed_book(s_base, "xmem", [gate]) + seed_book(p_base, "xmem", []) + call = {"cwd": repo, "session_id": "env-s1", "tool_name": "Bash", + "tool_input": {"command": "git push origin main"}} + + def denied(out): + try: + return json.loads(out)["hookSpecificOutput"].get("permissionDecision") == "deny" + except Exception: + return False + _, s_out = _hook_at(staging, home, ["pre"], call) + _, p_out = _hook_at(prod, home, ["pre"], dict(call, session_id="env-p1")) + check("env base: the staging install enforces the staging book; the prod install its own", + denied(s_out) and not denied(p_out), f"{s_out!r} | {p_out!r}") + check("env base: the fire lands in that install's ledger, never the other's or the shared one", + os.path.isfile(os.path.join(s_base, "ledger", "fires.jsonl")) + and not os.path.exists(os.path.join(p_base, "ledger", "fires.jsonl")) + and not os.path.exists(os.path.join(shared, "ledger", "fires.jsonl"))) + check("env base: session state is per install too", + os.path.isdir(os.path.join(s_base, "state")) + and not os.path.exists(os.path.join(shared, "state"))) + + # The pre-keying ledger: rows recorded before the upgrade and never flushed + # are still shipped, behind their own watermark, until it is drained. + sys.path.insert(0, os.path.dirname(os.path.abspath(HOOK))) + import rulebook_hook as H + import rulebook_paths + + def flush_lane(): # what `rulebook_hook.py flush` runs + H.flush_fires(final=True) + rulebook_paths.drain_legacy(vars(H), final=True) # the hook passes globals() + saved = (H.BASE, H.LEGACY_BASE, H._api, H._ACTIVE_BASE) + with tempfile.TemporaryDirectory() as td: + keyed, legacy = os.path.join(td, "rulebook", "api.example.test"), os.path.join(td, "rulebook") + for base, ids in ((keyed, ("new-1",)), (legacy, ("old-1", "old-2"))): + os.makedirs(os.path.join(base, "ledger"), exist_ok=True) + with open(os.path.join(base, "ledger", "fires.jsonl"), "w", encoding="utf-8") as f: + for i in ids: + f.write(json.dumps({"fire_id": i, "rule_id": "r", "fired_at": "t"}) + "\n") + posted, fail = [], {"on": True} + + class _R: + def __init__(self, n): + self.status = 500 if fail["on"] else 202 + self.data = None if fail["on"] else {"accepted": n, "rejected": 0} + + class _Http: + @staticmethod + def rest(url, bearer, method, body=None, headers=None, timeout=None): + rows = (body or {}).get("fires") or [] + if not fail["on"]: + posted.extend(r["fire_id"] for r in rows) + return _R(len(rows)) + try: + H.BASE, H.LEGACY_BASE, H._ACTIVE_BASE = keyed, legacy, "" + H._api = lambda: ("http://x", "t", _Http) + legacy_sent = os.path.join(legacy, "ledger", ".sent") + flush_lane() # server down + check("legacy ledger: a failed flush leaves the old rows behind their watermark", + H.load_sent().get("fires_offset", 0) == 0 and not os.path.exists(legacy_sent) + and posted == [], str(posted)) + fail["on"] = False + flush_lane() + check("legacy ledger: the pre-upgrade rows are flushed after this install's own", + posted == ["new-1", "old-1", "old-2"], str(posted)) + with open(legacy_sent, encoding="utf-8") as f: + lw = json.load(f) + check("legacy ledger: …and its own watermark reaches the end of the old file", + lw.get("fires_offset") == os.path.getsize(os.path.join(legacy, "ledger", "fires.jsonl")), str(lw)) + check("legacy ledger: the drain leaves this process on its own base", + H._base() == keyed and H._ledger_dir() == os.path.join(keyed, "ledger")) + real_flush, legacy_runs = H.flush_fires, [] + + def counting_flush(final=False): + if H._ACTIVE_BASE == legacy: + legacy_runs.append(1) + return real_flush(final=final) + H.flush_fires = counting_flush + try: + flush_lane() + finally: + H.flush_fires = real_flush + check("legacy ledger: a drained old ledger sends nothing again", + posted == ["new-1", "old-1", "old-2"], str(posted)) + check("legacy ledger: …and once drained it is not flushed at all (no per-flush cost)", + legacy_runs == [], str(legacy_runs)) + H.LEGACY_BASE = "" # MEMHUB_RULEBOOK_BASE in force + with open(os.path.join(legacy, "ledger", "fires.jsonl"), "a", encoding="utf-8") as f: + f.write(json.dumps({"fire_id": "old-3", "rule_id": "r", "fired_at": "t"}) + "\n") + flush_lane() + check("legacy ledger: with an override there is no old ledger to drain", + "old-3" not in posted, str(posted)) + # A send failure during the drain is loud where capture_health looks: + # the flush lane breadcrumbs under the base it is flushing (the old + # one), so the drain re-raises it in THIS install's .last_error. + H.LEGACY_BASE = legacy + with open(os.path.join(legacy, "ledger", "fires.jsonl"), "a", encoding="utf-8") as f: + f.write(json.dumps({"fire_id": "old-4", "rule_id": "r", "fired_at": "t"}) + "\n") + before = json.load(open(legacy_sent, encoding="utf-8"))["fires_offset"] + + class _Down: + @staticmethod + def rest(url, bearer, method, body=None, headers=None, timeout=None): + raise OSError("connection refused (harness)") + H._api = lambda: ("http://x", "t", _Down) + own_crumb = os.path.join(keyed, "ledger", ".last_error") + if os.path.exists(own_crumb): + os.unlink(own_crumb) + flush_lane() + try: + crumb = json.load(open(own_crumb, encoding="utf-8")) + except Exception: + crumb = {} + check("legacy ledger: a send failure while draining lands in this install's own .last_error", + crumb.get("what") == "flush" and "old shared ledger" in str(crumb.get("error")) + and "connection refused" in str(crumb.get("error")), str(crumb)) + check("legacy ledger: …and the old rows stay behind their watermark", + json.load(open(legacy_sent, encoding="utf-8"))["fires_offset"] == before) + with open(HOOK, encoding="utf-8") as f: + src = f.read() + lane = src[src.index('if mode == "flush":'):] + check("legacy ledger: the hook's flush lane runs the drain after its own flush", + lane.find("_rbp.drain_legacy(") > lane.find("flush_fires(final=final)") >= 0) + finally: + H.BASE, H.LEGACY_BASE, H._api, H._ACTIVE_BASE = saved + + def main() -> int: portability_check() + env_keyed_base_checks() # ENG-1201: one state dir per backend with tempfile.TemporaryDirectory() as td: repo = os.path.join(td, "xmem") # fake git repo named xmem os.makedirs(os.path.join(repo, ".git")) @@ -1320,9 +1592,11 @@ def main() -> int: tool_response={"stdout": "ok"}), cenv) with open(os.path.join(td, "ledger", "fires.jsonl"), encoding="utf-8") as f: rows = [json.loads(l) for l in f if l.strip() and '"cap-' in l] - modes = {r["rule_id"]: r["mode"] for r in rows} + modes = [(r["rule_id"], r["mode"]) for r in rows] check("cap: the cut rule is logged mode=suppressed, never silently dropped", - modes == {"cap-0": "advise", "cap-1": "advise", "cap-2": "suppressed"}, str(modes)) + modes[:3] == [("cap-0", "advise"), ("cap-1", "advise"), ("cap-2", "suppressed")], str(modes)) + check("cap: a rule the cap cut is not spent — it fires on the next matching call", + modes[3:] == [("cap-2", "advise")], str(modes)) check("cap: raw_matches_before_fire recorded", all(r["raw_matches_before_fire"] == 1 for r in rows)) with open(os.path.join(td, "ledger", "events.jsonl"), encoding="utf-8") as f: evs = [json.loads(l) for l in f if l.strip()] @@ -1575,10 +1849,77 @@ def _windows(end): "gives up quickly rather than reading it all", got is None and (time.time() - t0) < 1.0) - # --- what the recall lane sends --------------------------------------- - # /recall is the one lane that carries content: the relevance judge needs - # the call itself. A command line is also where credentials live, and they - # are worth nothing to the judge. + # --- anchor rules: matched locally, as whole identifiers (ENG-1203) ------ + anc = {"id": "a", "on": "anchor", "anchors": ["agent-plugins", "SKILL.md", + "app/core/config.py", "docs/specs/", "/"]} + for label, text, want in [ + ("a longer name containing the anchor does not match", + "cd ~/xtrace/agent-plugins-internal && ls", []), + ("the anchor itself matches", "gh repo view XTraceAI/agent-plugins", ["agent-plugins"]), + ("a file name matches after a directory", "cat skills/x/SKILL.md", ["SKILL.md"]), + ("a relative path matches the tail of an absolute one", + "/Users/f/MemHub-Backend/app/core/config.py", ["app/core/config.py"]), + ("…but not a longer file name", "app/core/config.pyc", []), + ("…nor a different directory prefix", "myapp/core/config.py", []), + ("a trailing-slash anchor matches the directory named whole", "ls docs/specs", ["docs/specs/"]), + ("…and any path under it", "cat docs/specs/x.md", ["docs/specs/"]), + ("…but not a longer directory name", "ls docs/specs-old/x.md", []), + ("case-sensitive", "cat skill.md", []), + ("a bare `/` anchor never matches", "ls /", []), + ]: + check("anchor_hits: %s" % label, rb.anchor_hits(anc, text) == want, str(rb.anchor_hits(anc, text))) + check("the /recall client is gone", not hasattr(rb, "recall_anchor_rules")) + + with tempfile.TemporaryDirectory() as td: + repo = os.path.join(td, "anchrepo") + os.makedirs(os.path.join(repo, ".git")) + with open(os.path.join(repo, ".git", "HEAD"), "w", encoding="utf-8") as f: + f.write("ref: refs/heads/main\n") + seed_book(td, "anchrepo", [ + _row("anc-cfg", delivery="anchor_recall", anchors=["config.py"], statement="CFG ANCHOR"), + _row("anc-scoped", delivery="anchor_recall", anchors=["models.py"], + statement="SCOPED ANCHOR", scope_paths=["app/**"]), + _row("anc-draft", delivery="anchor_recall", anchors=["config.py"], + statement="DRAFT ANCHOR", status="draft"), + _row("anc-other", delivery="anchor_recall", anchors=["config.py"], + statement="OTHER REPO ANCHOR", scope_repos=["some-other-repo"]), + ] + [_row("anc-cap%d" % i, delivery="anchor_recall", anchors=["cap.py"], + statement="CAP ANCHOR %d" % i) for i in (1, 2, 3)]) + # No token and no server: the lane must not need either. + aenv = {"MEMHUB_RULEBOOK_BASE": td, "MEMHUB_RULEBOOK_FETCH": "0", + "MEMHUB_RULEBOOK_JUDGE": "0", "MEMHUB_TOKEN": "", + "MEMHUB_MCP_BASE_URL": "http://127.0.0.1:9"} + + def edit(sid, path): + return run("pre", {"cwd": repo, "session_id": sid, "tool_name": "Edit", + "tool_input": {"file_path": os.path.join(repo, path), + "old_string": "a", "new_string": "b"}}, aenv)[1] + out = edit("an1", "src/config.py") + check("anchor lane: an edit naming the anchor fires it locally, with no server", + "CFG ANCHOR" in ctx(out), out) + check("anchor lane: a draft or other-repo anchor rule does not fire", + "DRAFT ANCHOR" not in ctx(out) and "OTHER REPO ANCHOR" not in ctx(out), out) + check("anchor lane: path scope — outside `app/**` the scoped rule stays quiet", + "SCOPED ANCHOR" not in ctx(edit("an2", "src/models.py"))) + check("anchor lane: path scope — inside `app/**` it fires", + "SCOPED ANCHOR" in ctx(edit("an3", "app/models.py"))) + out = run("pre", {"cwd": repo, "session_id": "an4", "tool_name": "Bash", + "tool_input": {"command": "python -m pytest app/models.py"}}, aenv)[1] + check("anchor lane: a Bash call carries no path, so an include-scoped rule stays quiet", + "SCOPED ANCHOR" not in ctx(out), out) + out1, out2 = ctx(edit("an6", "cap.py")), ctx(edit("an6", "cap.py")) + check("anchor lane: at most MAX_ADVISE anchors per call, first by book order; " + "the one left over is still unspent and fires on the next call", + "CAP ANCHOR 1" in out1 and "CAP ANCHOR 2" in out1 and "CAP ANCHOR 3" not in out1 + and "CAP ANCHOR 3" in out2 and "CAP ANCHOR 1" not in out2, out1 + " || " + out2) + out = run("pre", {"cwd": repo, "session_id": "an5", "tool_name": "Bash", + "tool_input": {"command": "python config.py"}}, + dict(aenv, MEMHUB_RULEBOOK_RECALL="0"))[1] + check("anchor lane: MEMHUB_RULEBOOK_RECALL=0 turns it off", "CFG ANCHOR" not in ctx(out), out) + + # --- credentials out of command text --------------------------------- + # The judge sees the call itself, and a command line is where credentials + # live; they are worth nothing to the judge. for label, cmd in [ ("Authorization header", 'curl -H "Authorization: Bearer sk-live-abcdefghij1234567890" https://x'), ("credentials in a URL", 'psql "postgres://admin:hunter2@db.internal/prod"'), @@ -1590,7 +1931,7 @@ def _windows(end): ("our own access key", 'curl -H "x: mhk_AbCdEf123456789" https://x'), ]: out = rb.redact_secrets(cmd) - check("recall redacts: %s" % label, + check("redact_secrets: %s" % label, "<redacted>" in out and "hunter2" not in out and "p4ssw0rd" not in out and "super-secret-value" not in out and "ghp_AAAABBBBCCCCDDDDEEEEFFFF1111" not in out and "AKIAIOSFODNN7EXAMPLE" not in out, out) @@ -1605,14 +1946,14 @@ def _windows(end): ('curl --user user:"pass"', 'curl --user admin:"s3cr3t pw" https://x.com', "s3cr3t pw"), ]: out = rb.redact_secrets(cmd) - check("recall redacts a quoted value: %s" % label, + check("redact_secrets: a quoted value: %s" % label, "<redacted>" in out and secret not in out, out) # Over-redaction is not free: it costs the judge the verb of the command. for cmd in ["git push --force origin main", "gh auth login", "kubectl get secrets", "npm run build -- --token-budget 500", "pytest tests/ -k rulebook", "ls -la ~/.ssh"]: - check("recall leaves an innocent command intact: %s" % cmd[:34], + check("redact_secrets leaves an innocent command intact: %s" % cmd[:34], rb.redact_secrets(cmd) == cmd, rb.redact_secrets(cmd)) check("redaction never raises on empty or None", @@ -2044,10 +2385,17 @@ def denied(cmd): apply_patch_checks() read_lane_checks() diff_base_checks() + spec_pending_probe_checks() armed_lane_checks() min_hook_version_checks() prompt_lane_checks() + edit_added_checks() + server_shape_edge_checks() spec_untouched_text_checks() + run_cache_checks() + fresh_book_fetch_checks() + first_fetch_checks() + ledger_rotation_checks() print() if FAILURES: @@ -2550,7 +2898,9 @@ def post(session, command, exit_code=0): ("Approach this assignment about staging", False), ("<system-reminder>staging</system-reminder>", True), ("Base directory for this skill: /x staging", True), - ("This session is being continued about staging", True)): + ("This session is being continued about staging", True), + ('Another Claude session sent a message:\n<agent-message from="a">' + '\n[Subagent hand-back] staging is broken', True)): check(f"harness_prompt({text[:34]!r}...) is {want}", rb_mod.harness_prompt(text) is want, str(rb_mod.harness_prompt(text))) @@ -2651,6 +3001,546 @@ def post(session, command, exit_code=0): rb_mod.to_hook_rule(dict(probe_rule)) is not None) +def fresh_book_fetch_checks() -> None: + """SessionStart and a prompt start NO fetch child when the cached book is + younger than REFRESH_AFTER_S: one was spawned on every SessionStart, a + fresh interpreter re-fetching a book a minute old. A stale book is still + fetched synchronously, bounded, and still starts no child.""" + import sys as _sys + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_hook as H + + calls = [] + saved = (H.spawn_fetch, H.fetch_book, H.load_rules, os.environ.get("MEMHUB_RULEBOOK_FETCH")) + H.spawn_fetch = lambda repo: calls.append(("spawn", repo)) + H.fetch_book = lambda repo, timeout=None: calls.append(("fetch", repo, timeout)) + H.load_rules = lambda repo: (["new"], "v", H._now(), {"s": 2}) + os.environ["MEMHUB_RULEBOOK_FETCH"] = "1" + try: + fresh = H._now() + out = H.refresh_if_stale("r", ["old"], fresh, {"s": 1}) + check("fresh book: no detached fetch child, no fetch, the cache as it is", + calls == [] and out == (["old"], fresh, {"s": 1}), str((calls, out))) + stale = (datetime.now(timezone.utc) - timedelta(seconds=H.REFRESH_AFTER_S + 5)).isoformat() + out = H.refresh_if_stale("r", ["old"], stale, {"s": 1}) + check("stale book: one bounded synchronous fetch, then the reloaded book, no child", + calls == [("fetch", "r", H.SESSION_FETCH_TIMEOUT_S)] and out[0] == ["new"], + str((calls, out))) + finally: + H.spawn_fetch, H.fetch_book, H.load_rules = saved[:3] + if saved[3] is None: + os.environ.pop("MEMHUB_RULEBOOK_FETCH", None) + else: + os.environ["MEMHUB_RULEBOOK_FETCH"] = saved[3] + + +def first_fetch_checks() -> None: + """A call in a repo with NO cached book gets one bounded synchronous fetch + before it is judged (ENG-1249: a session started above a bare `.git` and + its worktrees had no SessionStart fetch, and the detached refresh landed + after the first edit — which fired nothing). Once a minute per repo through + the `.refresh` stamp, so a dead server costs one timeout a minute; a cached + book, stale or not, is still maybe_refresh's detached business.""" + import sys as _sys + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_hook as H + + calls = [] + saved = (H.spawn_fetch, H.fetch_book, H.load_rules, H.upgrade_status, H.BASE, + os.environ.get("MEMHUB_RULEBOOK_FETCH")) + H.spawn_fetch = lambda repo: calls.append(("spawn", repo)) + H.fetch_book = lambda repo, timeout=None: calls.append(("fetch", repo, timeout)) + H.load_rules = lambda repo: (["new"], "v", H._now(), {"s": 2}) + H.upgrade_status = lambda repo: None + os.environ["MEMHUB_RULEBOOK_FETCH"] = "1" + try: + with tempfile.TemporaryDirectory() as td: + H.BASE = td + out = H.first_fetch("r", [], None, {}) + check("no cached book: one bounded synchronous fetch, then the reloaded book, no child", + calls == [("fetch", "r", H.SESSION_FETCH_TIMEOUT_S)] and out == (["new"], out[1], {"s": 2}) + and out[1] is not None, str((calls, out))) + calls.clear() + out = H.first_fetch("r", [], None, {}) + check("still no book a moment later (server down): no second fetch within the minute", + calls == [] and out == ([], None, {}), str((calls, out))) + H.maybe_refresh("r", None) + check("…and maybe_refresh starts no detached child either: the stamp is shared", + calls == [], str(calls)) + os.remove(H.book_path("r") + ".refresh") + H.maybe_refresh("r", None) + check("stamp gone: maybe_refresh's detached child again, as before", + calls == [("spawn", "r")], str(calls)) + calls.clear() + stale = (datetime.now(timezone.utc) - timedelta(seconds=H.REFRESH_AFTER_S + 5)).isoformat() + out = H.first_fetch("s", ["old"], stale, {"s": 1}) + check("a cached book, however stale, is not first_fetch's: untouched, no fetch", + calls == [] and out == (["old"], stale, {"s": 1}), str((calls, out))) + out = H.first_fetch("", [], None, {}) + # A projectless Claude call's book (repo="": rules binding every + # repo) is fetched like any other; Codex never reaches here. + check("no repo named: the projectless book is fetched like any other", + calls == [("fetch", "", H.SESSION_FETCH_TIMEOUT_S)] and out[0] == ["new"], str((calls, out))) + calls.clear() + H.upgrade_status = lambda repo: {"scope": "org", "minimum_version": "9.9.9"} + out = H.first_fetch("u", [], None, {}) + check("an upgrade notice suspends the book: no fetch", calls == [], str(calls)) + H.upgrade_status = lambda repo: None + os.environ["MEMHUB_RULEBOOK_FETCH"] = "0" + out = H.first_fetch("f", [], None, {}) + check("MEMHUB_RULEBOOK_FETCH=0: nothing", calls == [], str(calls)) + finally: + H.spawn_fetch, H.fetch_book, H.load_rules, H.upgrade_status, H.BASE = saved[:5] + if saved[5] is None: + os.environ.pop("MEMHUB_RULEBOOK_FETCH", None) + else: + os.environ["MEMHUB_RULEBOOK_FETCH"] = saved[5] + + +_ROTATION_CRASH = r""" +import os, sys +sys.path.insert(0, sys.argv[1]) +import rulebook_hook as H +H.BASE = sys.argv[2] +when, real = sys.argv[3], os.replace +def replace(src, dst): + if when == "before-intent" and dst.endswith(".sent") and "rotating" in open(src).read(): + os._exit(9) + if when == "before-swap" and dst.endswith("fires.jsonl"): + os._exit(9) + real(src, dst) + if when == "after-swap" and dst.endswith("fires.jsonl"): + os._exit(9) +H.os.replace = replace +H.rulebook_ledger.rotate(os.path.join(H.BASE, "ledger"), [x[:2] for x in H.LEDGERS], + H._LEDGER_TAIL_BYTES) +os._exit(0) +""" + + +def ledger_rotation_checks() -> None: + """fires.jsonl / events.jsonl are rotated (ENG-1201) — and rotation may + only ever drop rows the server already confirmed: rows below the `.sent` + watermark. Every check here compares what the NEXT flush would post with + exactly the rows that were unsent before, so a lost row and a re-sent row + both fail it. Crashes are real (`os._exit` in a child) at each step.""" + import sys as _sys + import threading + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_hook as H + L = H.rulebook_ledger + + def rotate(ldir): # what flush_fires runs first, under .flush.lock + L.rotate(ldir, [x[:2] for x in H.LEDGERS], H._LEDGER_TAIL_BYTES) + + saved_base = H.BASE + + def setup(td, n=3000, sent_rows=2000, pad=480): + H.BASE = td + ldir = H._ledger_dir() + rows = [{"fire_id": f"f{i:05d}", "rule_id": "r", "session_id": f"s{i % 7}", + "fired_at": f"2026-10-05T00:{i // 60 % 60:02d}:{i % 60:02d}+00:00", + "excerpt": "x" * pad} for i in range(n)] + data = b"".join((json.dumps(r) + "\n").encode() for r in rows) + with open(os.path.join(ldir, "fires.jsonl"), "wb") as f: + f.write(data) + wm = sum(len((json.dumps(r) + "\n").encode()) for r in rows[:sent_rows]) + H._atomic_json(H._sent_path(), {"fires_offset": wm, "events_offset": 0, + "last_flush_at": None}) + return ldir, [r["fire_id"] for r in rows], wm + + def next_flush_ids(): + batches, _ = H.pending_batches(H.load_sent()) + return [r["fire_id"] for b, _ in batches for r in b] + + def on_disk_ids(ldir): + return [json.loads(x)["fire_id"] for x in + open(os.path.join(ldir, "fires.jsonl"), "rb").read().splitlines() if x] + + try: + # ── the happy path ─────────────────────────────────────────────── + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td) + size0 = os.path.getsize(os.path.join(ldir, "fires.jsonl")) + tails = {f"s{k}": H._earliest_fire_after(f"s{k}", "2026-10-05T00:00:00+00:00") + for k in range(7)} + rotate(ldir) + kept = on_disk_ids(ldir) + size1 = os.path.getsize(os.path.join(ldir, "fires.jsonl")) + check("rotation: a ledger past 1 MB shrinks", size0 > L.ROTATE_AT and size1 < size0, + f"{size0} -> {size1}") + check("rotation: what stays is a whole-line suffix of the ledger (every unsent row kept)", + kept == ids[len(ids) - len(kept):] and len(kept) >= 1000, str(len(kept))) + check("rotation: the next flush posts exactly the rows that were unsent", + next_flush_ids() == ids[2000:], str(next_flush_ids()[:3])) + check("rotation: the newest 256 KB stay, so the turn_end tail reader sees the same fires", + size1 >= H._LEDGER_TAIL_BYTES - 1024 and tails == { + k: H._earliest_fire_after(k, "2026-10-05T00:00:00+00:00") for k in tails}, + str(size1)) + sent = H.load_sent() + check("rotation: no intent and no half-written file left behind", + "rotating" not in sent and not [n for n in os.listdir(ldir) if n.startswith(".rot-")], + str((sent, os.listdir(ldir)))) + rotate(ldir) + check("rotation: a second pass with nothing confirmed to drop changes nothing", + on_disk_ids(ldir) == kept and next_flush_ids() == ids[2000:]) + + # Mostly confirmed: the cut is set by the 256 KB tail, not the + # watermark, so confirmed rows stay above the cut and the watermark + # must move down by exactly the cut. + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td, sent_rows=2900) + tails = {f"s{k}": H._earliest_fire_after(f"s{k}", "2026-10-05T00:00:00+00:00") + for k in range(7)} + rotate(ldir) + kept = on_disk_ids(ldir) + check("rotation (tail-bound cut): keeps a whole-line suffix past the unsent rows", + kept == ids[len(ids) - len(kept):] and 100 < len(kept) < 1000, str(len(kept))) + check("rotation (tail-bound cut): the next flush posts exactly the unsent rows", + next_flush_ids() == ids[2900:], str(next_flush_ids()[:3])) + check("rotation (tail-bound cut): the tail reader sees the same fires", + os.path.getsize(os.path.join(ldir, "fires.jsonl")) >= H._LEDGER_TAIL_BYTES - 1024 + and tails == {k: H._earliest_fire_after(k, "2026-10-05T00:00:00+00:00") + for k in tails}) + + # flush_fires runs it, under its lock, before (and whatever) it posts + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td) + real_api, H._api = H._api, lambda: None # no credential: nothing posts + try: + H.flush_fires(final=True) + finally: + H._api = real_api + check("rotation: flush_fires rotates an oversized ledger, exactly", + len(on_disk_ids(ldir)) < len(ids) and next_flush_ids() == ids[2000:]) + + # ── when it must not rotate ────────────────────────────────────── + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td, n=1500) # < 1 MB + rotate(ldir) + check("rotation: a ledger under 1 MB is left alone", on_disk_ids(ldir) == ids) + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td, sent_rows=200) # mostly unsent + rotate(ldir) + check("rotation: little confirmed → nothing to gain, nothing cut", + on_disk_ids(ldir) == ids and next_flush_ids() == ids[200:]) + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td) + with open(os.path.join(ldir, "conversions.jsonl"), "w") as f: + f.write(json.dumps({"fire_id": "f00001", "converted": True}) + "\n") + rotate(ldir) + check("rotation: fires stay whole while pre-v0.59 verdicts wait to drain " + "(the drain reads fire rows from the start)", on_disk_ids(ldir) == ids) + + # ── a crash at every step ──────────────────────────────────────── + for when, n_sent in [(w, n) for w in ("before-intent", "before-swap", "after-swap") + for n in (2000, 2900)]: + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td, sent_rows=n_sent) + p = subprocess.run([sys.executable, "-c", _ROTATION_CRASH, + os.path.dirname(HOOK), td, when], + capture_output=True, text=True, timeout=60) + crashed = p.returncode == 9 + H.BASE = td + pending = H.load_sent().get("rotating") + L.finish(ldir) # what the next flush does first + check(f"rotation crash {when}/{n_sent}: the next flush posts exactly the unsent rows", + crashed and next_flush_ids() == ids[n_sent:], + f"rc={p.returncode} {p.stderr[-300:]} intent={pending} " + f"first={next_flush_ids()[:2]} n={len(next_flush_ids())}") + check(f"rotation crash {when}/{n_sent}: the intent and any half-written file are gone", + "rotating" not in H.load_sent() + and not [n for n in os.listdir(ldir) if n.startswith(".rot-")]) + rotate(ldir) # and a later rotation still works + check(f"rotation crash {when}/{n_sent}: a later rotation completes, still exact", + next_flush_ids() == ids[n_sent:] and len(on_disk_ids(ldir)) < len(ids)) + + # ── an append waits for a rotation in progress ────────────────── + with tempfile.TemporaryDirectory() as td: + ldir, ids, wm = setup(td, n=10) + lock = L.append_lock(ldir) + done = threading.Event() + t = threading.Thread(target=lambda: (H.log_event({"session": "s"}, "turn_end"), done.set())) + t.start() + waited = not done.wait(0.5) + L.release(lock) + t.join(10) + ev = os.path.join(ldir, "events.jsonl") + check("rotation: a ledger append waits while a rotation holds the append lock", + waited and done.is_set() and os.path.exists(ev) and len(open(ev).readlines()) == 1) + finally: + H.BASE = saved_base + + +def server_shape_edge_checks() -> None: + """Two rule shapes the server accepts that this hook used to drop silently: + a regex past 400 characters (the server allows 2000, floored at 0.88.0) and + an edit rule with content_rx but no path_rx (validation takes either).""" + import sys as _sys + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_hook as H + + long_rx = "longpattern-hit|" + "|".join("alt%04d" % i for i in range(70)) + r = H.to_hook_rule(_row("long-rx", {"event": "bash", "command_rx": long_rx}, + min_hook_version="0.88.0")) + check("edge: a %d-char regex the server accepted loads" % len(long_rx), + len(long_rx) > 400 and r is not None, str(r)[:200]) + check("edge: …and fires on its command", + r is not None and bool(H.evaluate(r, hook_phase="pre", tool="Bash", + cmd="echo longpattern-hit"))) + check("edge: a regex past the server's 2000 cap is still dropped", + H.to_hook_rule(_row("too-long", {"event": "bash", "command_rx": "a" * 2001})) is None) + + r = H.to_hook_rule(_row("content-only", {"event": "edit", "content_rx": "TODO"})) + check("edge: a content-only edit rule loads", r is not None, str(r)) + check("edge: …and fires on any path whose body matches", + r is not None and bool(H.evaluate(r, hook_phase="pre", tool="Edit", + file_path="/x/a.py", body="# TODO later"))) + check("edge: …and stays silent when the body does not match", + r is not None and not H.evaluate(r, hook_phase="pre", tool="Edit", + file_path="/x/a.py", body="# done")) + + +def run_cache_checks() -> None: + """The hook process keeps two caches so a tool call pays for neither the + per-row lint of an unchanged book nor a `git` subprocess to name an + unchanged checkout. Both must be invisible: a hit returns exactly what a + cold load returns, any change to what the answer depends on misses, and a + corrupt cache is the cold path. In-process, `H._rc` is set by hand (only + `__main__` imports rulebook_cache) and BASE points at a temp dir.""" + import sys as _sys + _sys.path.insert(0, os.path.join(os.path.dirname(HOOK))) + import rulebook_cache as RC + import rulebook_hook as H + RI = H.repo_identity # the hook's own pinned copy, not a second import + + rows = [ + _row("rc-bash", {"event": "bash", "command_rx": r"\bgit\s+push\b", + "command_not_rx": "--dry-run", "warn_once_per": "turn"}, mode="gate"), + _row("rc-given", {"event": "bash", "command_rx": r"\bpytest\b", + "given": {"repo": {"branch_rx": "^main$"}}}), + _row("rc-output", {"event": "output", "command_rx": "pytest", "content_rx": "FAILED"}), + _row("rc-prompt", {"event": "prompt", "prompt_rx": "ship it"}), + _row("rc-session", delivery="session_context"), + _row("rc-future", {"event": "bash", "command_rx": "deploy"}, mode="gate", + min_hook_version="99.0.0"), + _row("rc-bad-rx", {"event": "bash", "command_rx": "(a+)+"}), + _row("rc-bash", {"event": "bash", "command_rx": "dup"}), # duplicate id: first wins + {"id": "rc-pilot", "on": "edit", "path_rx": r"\.py$", "text": "pilot", "why": "w"}, + ] + old_base, old_flag = H.BASE, H._rc + with tempfile.TemporaryDirectory() as td: + H.BASE = td + try: + seed_book(td, "cache-repo", rows) + book = H.book_path("cache-repo") + norm = book + ".norm" + H._rc = None + fresh = H.load_rules("cache-repo") + check("cache: an import never writes the normalised book", not os.path.exists(norm)) + H._rc = RC + cold = H.load_rules("cache-repo") + check("cache: the hook process writes it beside the book", os.path.exists(norm)) + + calls = [] + real = H.to_hook_rule + H.to_hook_rule = lambda row: calls.append(row) or real(row) + try: + hit = H.load_rules("cache-repo") + check("cache: a hit re-lints no row", calls == [], str(len(calls))) + check("cache: a hit returns exactly the cold load (rules, fetched_at, sources)", + hit == cold == fresh and [r["id"] for r in hit[0]][0] == "rc-bash" + and "rc-bad-rx" not in hit[3] and len(hit[0]) == 7, str(hit)[:300]) + + # Same length, same file (in place, so the inode stays): only the bytes differ. + with open(book, encoding="utf-8") as f: + text = f.read() + with open(book, "w", encoding="utf-8") as f: + f.write(text.replace("rc-session text", "rc-sessXon text")) + calls.clear() + edited = H.load_rules("cache-repo") + check("cache: an edited book misses and is re-linted", + len(calls) == len(rows) + and any(r.get("text") == "rc-sessXon text" for r in edited[0]), + str([r.get("text") for r in edited[0]])) + + H._HOOK_VERSION[:] = [(99, 0, 0)] + calls.clear() + newer = H.load_rules("cache-repo") + future = [r for r in newer[0] if r["id"] == "rc-future"] + check("cache: a different hook version misses (min_hook_version re-judged)", + len(calls) == len(rows) and future and future[0].get("mode") == "gate" + and "_degraded" not in future[0], str(future)) + H._HOOK_VERSION[:] = [] + + # the last one carries the RIGHT key: only the row check refuses it + real_key = [H.hashlib.sha1(open(book, "rb").read()).hexdigest(), + RC.hook_identity(H.__file__, H.hook_version(), H._ACTIVE_BASE)] + for garbage in ("{not json", json.dumps({"key": "x", "rules": []}), + json.dumps({"key": real_key, "rules": [{"id": ""}]})): + with open(norm, "w", encoding="utf-8") as f: + f.write(garbage) + calls.clear() + H._HOOK_VERSION[:] = [] + got = H.load_rules("cache-repo") + H._rc = None + want = H.load_rules("cache-repo") + H._rc = RC + check("cache: a corrupt or foreign cache is the cold path (%s…)" % garbage[:12], + got == want and len(calls) >= len(rows), str(got)[:200]) + finally: + H.to_hook_rule = real + H._HOOK_VERSION[:] = [] + + # End to end, through the real process: the second call is served + # from the cache and fires the same gate with the same text. + repo = os.path.join(td, "cache-repo") + os.makedirs(os.path.join(repo, ".git")) + with open(os.path.join(repo, ".git", "HEAD"), "w", encoding="utf-8") as f: + f.write("ref: refs/heads/feature\n") + os.remove(norm) + env = {"MEMHUB_RULEBOOK_BASE": td, "MEMHUB_RULEBOOK_FETCH": "0"} + outs = [] + for i in range(2): + _, out = run("pre", {"session_id": f"rc-{i}", "cwd": repo, "tool_name": "Bash", + "tool_input": {"command": "git push origin feature"}}, env) + outs.append(out) + check("cache: the hook process writes the cache on a cold call", os.path.exists(norm)) + check("cache: a cached call denies with the same text as the cold one", + '"deny"' in outs[0] and outs[0] == outs[1].replace("rc-1", "rc-0"), outs[1][:300]) + with open(norm, "w", encoding="utf-8") as f: + f.write("\0garbage") + _, out = run("pre", {"session_id": "rc-0", "cwd": repo, "tool_name": "Bash", + "tool_input": {"command": "git push origin feature"}}, env) + check("cache: a corrupt cache still gates, silently", out == outs[0], out[:300]) + finally: + H.BASE, H._rc = old_base, old_flag + + # The repo name: no `git` on a hit, a new name the moment the remote moves. + with tempfile.TemporaryDirectory() as td: + H.BASE = td + H._rc = RC + real_remote = RI._remote_basename + try: + main = os.path.join(td, "dir-name") + os.makedirs(main) + _git(main, "init", "-q") + _git(main, "remote", "add", "origin", "https://github.com/Org/first.git") + gitdir = os.path.join(main, ".git") + names = os.path.join(td, RC.REPO_NAMES_FILE) + + def name(): + RC._REPO_NAME_MEMO.clear() + RI._CACHE.clear() + return H.repo_info(main)[0] + + check("repo cache: a cold call names the repo from its remote", name() == "first") + check("repo cache: …and stores it under BASE", os.path.exists(names)) + spawned = [] + RI._remote_basename = lambda d: spawned.append(d) or real_remote(d) + check("repo cache: a hit spawns no git", name() == "first" and spawned == [], str(spawned)) + _git(main, "remote", "set-url", "origin", "https://github.com/Org/second.git") + check("repo cache: a changed remote URL misses and re-resolves", + name() == "second" and spawned, str(spawned)) + + # A linked worktree reads the MAIN checkout's config. + wt = os.path.join(td, "wt-dir") + _git(main, "commit", "-q", "--allow-empty", "-m", "x") + _git(main, "worktree", "add", "-q", wt) + RC._REPO_NAME_MEMO.clear() + first = H.repo_info(wt)[0] + _git(main, "remote", "set-url", "origin", "https://github.com/Org/third.git") + RC._REPO_NAME_MEMO.clear() + RI._CACHE.clear() + check("repo cache: a worktree follows its main checkout's remote", + first == "second" and H.repo_info(wt)[0] == "third", f"{first} {H.repo_info(wt)[0]}") + + with open(names, "w", encoding="utf-8") as f: + f.write("[corrupt") + check("repo cache: a corrupt map re-resolves", name() == "third") + + # git could not answer (missing / past its half second): the + # fallback name is served but never stored. + os.remove(names) + RI._remote_basename = lambda d: None + check("repo cache: an unsettled lookup falls back to the directory", + name() == "dir-name") + check("repo cache: …and is not stored", not os.path.exists(names)) + RI._remote_basename = real_remote + check("repo cache: the next call asks git again", name() == "third") + + # A git that fails for any reason but "no such remote" (exit 128, + # say) did not answer: its fallback is served, never stored. + os.remove(names) + fake_bin = os.path.join(td, "fake-bin") + os.makedirs(fake_bin) + with open(os.path.join(fake_bin, "git"), "w", encoding="utf-8") as f: + f.write("#!/bin/sh\necho 'fatal: unexpected failure' >&2\nexit 128\n") + os.chmod(os.path.join(fake_bin, "git"), 0o755) + old_path = os.environ["PATH"] + os.environ["PATH"] = fake_bin + os.pathsep + old_path + try: + check("repo cache: a git failing with exit 128 falls back to the directory", + name() == "dir-name") + finally: + os.environ["PATH"] = old_path + check("repo cache: …and that guess is not stored", not os.path.exists(names)) + # "No such remote" IS an answer: a remote-less checkout's name is stored. + bare = os.path.join(td, "no-origin") + os.makedirs(bare) + _git(bare, "init", "-q") + RC._REPO_NAME_MEMO.clear() + RI._CACHE.clear() + check("repo cache: a checkout with no origin is named by its directory and stored", + H.repo_info(bare)[0] == "no-origin" and os.path.exists(names) + and any(k.startswith(bare + "\0") for k in json.load(open(names))), + str(os.path.exists(names))) + finally: + RI._remote_basename = real_remote + RC._REPO_NAME_MEMO.clear() + RI._CACHE.clear() + H.BASE, H._rc = old_base, old_flag + + +def edit_added_checks() -> None: + """An edit rule's `content_rx` reads what the edit ADDED, not the lines its + new_string kept: a gate on `create_task(` blocked an Edit that only kept an + existing call (24 such files in MemHub-Backend app/), and comment rules + fired on comments the edit merely re-wrapped. `content_not_rx` still reads + the whole new text — the complied-with form may sit on a kept line.""" + with tempfile.TemporaryDirectory() as td: + repo = os.path.join(td, "editrepo") + os.makedirs(repo) + assert _git(repo, "-c", "init.defaultBranch=main", "init", "-q").returncode == 0 + env = {"MEMHUB_RULEBOOK_BASE": td, "MEMHUB_RULEBOOK_FETCH": "0", "MEMHUB_RULEBOOK_JUDGE": "0"} + seed_book(td, "editrepo", [_row("task-gate", {"event": "edit", "path_rx": r"\.py$", + "content_rx": r"create_task\(", + "content_not_rx": "# task-ok"}, + mode="gate", statement="No bare create_task")]) + kept = "x = 1\nasyncio.create_task(job())" + + def edit(sid, old, new, **inp): + ti = dict({"file_path": os.path.join(repo, "a.py"), "old_string": old, "new_string": new}, **inp) + return run("pre", {"cwd": repo, "session_id": sid, "tool_name": "Edit", "tool_input": ti}, env)[1] + + out = edit("e1", kept, kept + "\ny = 2") + check("edit: an Edit that only KEEPS a create_task line is not blocked", "task-gate" not in out, out) + out = edit("e2", "x = 1", kept) + check("edit: an Edit that ADDS a create_task line is", "task-gate" in out, out) + out = edit("e3", "x = 1", kept + " # task-ok") + check("edit: content_not_rx still exempts the added line", "task-gate" not in out, out) + out = edit("e4", "x = 1\nasyncio.create_task(job()) # task-ok", "x = 2\nasyncio.create_task(job()) # task-ok") + check("edit: a kept, complied-with line beside a changed one stays quiet", "task-gate" not in out, out) + out = run("pre", {"cwd": repo, "session_id": "e5", "tool_name": "MultiEdit", "tool_input": { + "file_path": os.path.join(repo, "a.py"), + "edits": [{"old_string": kept, "new_string": kept + "\nz = 3"}, + {"old_string": "q = 0", "new_string": "q = 0\nloop.create_task(other())"}]}}, env)[1] + check("edit: MultiEdit — the one edit that adds a create_task line fires", "task-gate" in out, out) + out = run("pre", {"cwd": repo, "session_id": "e6", "tool_name": "Write", "tool_input": { + "file_path": os.path.join(repo, "b.py"), "content": kept}}, env)[1] + check("edit: a Write is still read whole (its old file is not read)", "task-gate" in out, out) + + def prompt_lane_checks() -> None: """`event: prompt` (ENG-1184): a rule that speaks when a wake-up arrives. A `/loop` tick or a `<task-notification>` reaches the hook only as a @@ -2770,6 +3660,24 @@ def fires(rid): "tool_input": {"command": "echo '<task-notification>'"}}, env)[1]) check("prompt: a prompt rule never fires on a tool call", c == "", c) + # Another agent's message — a subagent's hand-back — is nobody's prompt: + # a rule written for what the person asked must not fire on a report + # that merely mentions it. A wake-up still does (the lane's purpose). + spec = _row("spec-ask", {"event": "prompt", "prompt_rx": r"\bspec\b", + "warn_once_per": "turn"}) + seed_book(td, "promptrepo", [spec, every]) + out = prompt("h1", "find the spec behind this work") + check("prompt: a typed prompt fires a prompt rule", "spec-ask" in out, out) + handback = ('Another Claude session sent a message:\n<agent-message from="a1">\n' + "[Subagent hand-back] I read the spec and the code.\n" + wake + "\n</agent-message>") + n_spec, n_every = len(fires("spec-ask")), len(fires("every-tick")) + out = prompt("h1", handback) + check("prompt: a subagent hand-back fires no prompt rule, even one it quotes a wake-up for", + out.strip() == "" and len(fires("spec-ask")) == n_spec + and len(fires("every-tick")) == n_every, out) + out = prompt("h1", wake) + check("prompt: …a real wake-up still fires", "every-tick" in out, out) + def min_hook_version_checks() -> None: """A rule can be newer than the hook reading it, and that used to be @@ -3479,5 +4387,6 @@ def decision(out): H.BASE = old_base + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/rulebook_repo_of_call_test.py b/tests/rulebook_repo_of_call_test.py index 8d081e8f..25d67c94 100644 --- a/tests/rulebook_repo_of_call_test.py +++ b/tests/rulebook_repo_of_call_test.py @@ -16,11 +16,14 @@ back to the MAIN checkout, so a rule scoped to the repo matches from any of its worktrees — the machinery was always correct, only the starting point was wrong; -* a Bash call carries no path, so a non-git cwd stays silent exactly as - before (the fail-open property the hook is built on); -* the session cwd is the trust boundary: a path outside it is ignored, a - symlink under it cannot smuggle the lookup out, and a relative path binds - to the SESSION's cwd rather than this process's; +* a Bash call that names no directory keeps the cwd, so a non-git cwd stays + silent exactly as before (the fail-open property the hook is built on); +* what the call TOUCHES decides, wherever it is: a file in another checkout, + or a Bash command's `cd` chain / `git -C` (`~`, `$HOME`, `$VAR`, relative + paths resolved), so the target repo's book judges it — not the session's + (2026-10 audit: 28 of 56 explained misses were a call judged by the + session repo's book); a target that is no checkout keeps the cwd answer; + a relative path binds to the SESSION's cwd rather than this process's; * `root` comes back in the session's own path space, because it keys OrderingEngine state (`{rid}@{root}:{branch}`) and one worktree reached two ways would split into two keys and silently re-arm its rules; @@ -125,7 +128,7 @@ def mkworktree(parent: str, main_repo: str, name: str, branch: str) -> str: def unit_checks() -> None: - """`repo_of_call` and `_acted_on_dir` are pure and importable — replayed + """`repo_of_call` and `_call_seed` are pure and importable — replayed here, so what is tested is what runs.""" with tempfile.TemporaryDirectory() as td: container = os.path.join(td, "container") # holds checkouts, is not one @@ -163,7 +166,8 @@ def call(cwd, **inp): # --- no path to act on: unchanged, still silent ---------------------- check("a Bash call from a container cwd still resolves nothing", - call(container, command="git push") == ("", "", "", "")) + rb.repo_of_call({"cwd": container, "tool_name": "Bash", + "tool_input": {"command": "git push"}}) == ("", "", "", "")) check("no tool_input at all (SessionStart) still resolves nothing", rb.repo_of_call({"cwd": container}) == ("", "", "", "")) # Assert the PAIR: main and the worktree now share a repo NAME, so a @@ -172,33 +176,40 @@ def call(cwd, **inp): check("a cwd inside a checkout is unaffected when no file is named", rb.repo_of_call({"cwd": wt})[:2] == ("MainRepo", wt)) - # --- the trust boundary --------------------------------------------- - check("an absolute path OUTSIDE the session cwd is ignored", - call(container, file_path=os.path.join(outside, "app", "svc.py")) - == ("", "", "", "")) - - link = os.path.join(container, "escape") - try: - os.symlink(outside, link) - check("a symlink under cwd cannot smuggle the lookup outside it", - call(container, file_path=os.path.join(link, "app", "svc.py")) - == ("", "", "", "")) - except (OSError, NotImplementedError): # unprivileged Windows - print("SKIP symlink containment (symlinks unavailable)") - - # The DANGEROUS direction, and the one a resolved-only check misses: a - # link OUTSIDE cwd whose target is inside it. Containment on the real - # path passes, while the lexical parents still lead to another - # checkout — which is the one `repo_info` would walk up into. - inward = os.path.join(td, "outside-link") - try: - os.symlink(os.path.join(container, "MainRepo-feature"), inward) - check("a symlink outside cwd pointing INTO it does not escape the boundary", - call(container, file_path=os.path.join(inward, "app", "svc.py")) - == ("", "", "", ""), - str(call(container, file_path=os.path.join(inward, "app", "svc.py")))) - except (OSError, NotImplementedError): - print("SKIP inward-symlink containment (symlinks unavailable)") + # --- the call's own target decides, wherever it is ------------------ + def bash(cwd, command, home=None): + saved = os.environ.get("HOME") + if home is not None: + os.environ["HOME"] = home + try: + return rb.repo_of_call({"cwd": cwd, "tool_name": "Bash", + "tool_input": {"command": command}}) + finally: + if home is not None: + os.environ["HOME"] = saved if saved is not None else "" + + check("an Edit of a file in ANOTHER checkout resolves that checkout", + call(wt, file_path=os.path.join(outside, "app", "svc.py"))[:2] + == ("OutsideRepo", outside)) + check("`cd <other repo> && git push` from a checkout resolves the other repo", + bash(wt, f"cd {outside} && git push")[:2] == ("OutsideRepo", outside)) + check("`cd ~/<other repo> && git push` expands ~ and resolves it", + bash(wt, "cd ~/OutsideRepo && git push", home=td)[:2] == ("OutsideRepo", outside)) + check("`cd $HOME/…` and `${HOME}` expand too", + bash(wt, "cd $HOME/OutsideRepo && git push", home=td)[:2] == ("OutsideRepo", outside) + and bash(wt, 'cd "${HOME}/OutsideRepo"; git push', home=td)[:2] == ("OutsideRepo", outside)) + check("a relative `cd ../..` chain resolves against the cwd", + bash(wt, "cd .. && cd ../OutsideRepo && git status")[:2] == ("OutsideRepo", outside)) + check("`git -C <other repo>` resolves the other repo", + bash(wt, f"git -C {outside} push")[:2] == ("OutsideRepo", outside)) + check("a `cd` into a subdirectory of the same checkout keeps it", + bash(wt, "cd app && ls")[:2] == ("MainRepo", wt)) + check("a `cd` to a directory that is no checkout keeps the cwd's", + bash(wt, f"cd {td} && ls")[:2] == ("MainRepo", wt)) + check("an unresolvable `cd \"$(…)\"` keeps the cwd's", + bash(wt, f'cd "$(echo {outside})" && git push')[:2] == ("MainRepo", wt)) + check("an unset variable keeps the cwd's", + bash(wt, "cd $NO_SUCH_VAR_RB && git push")[:2] == ("MainRepo", wt)) # --- one path space per session (the OrderingEngine key) ------------ # An absolute path spelled in a different-but-equivalent space @@ -266,12 +277,11 @@ def call(cwd, **inp): call(container, notebook_path=os.path.join(wt, "app", "n.ipynb"))[:2] == ("MainRepo", wt)) check("a junk file_path does not shadow a good notebook_path", - rb._acted_on_dir(container, {"file_path": 123, - "notebook_path": os.path.join(wt, "app", "n.ipynb")}) - == os.path.join(wt, "app")) + call(container, file_path=123, notebook_path=os.path.join(wt, "app", "n.ipynb"))[:2] + == ("MainRepo", wt)) check("a file that resolves nowhere falls back to the cwd's checkout", rb.repo_of_call({"cwd": wt, "tool_input": - {"file_path": os.path.join(outside, "a.py")}})[0] + {"file_path": os.path.join(td, "loose", "a.py")}})[0] == "MainRepo") @@ -321,15 +331,68 @@ def hook_checks() -> None: check("Bash from a container cwd is still silent, exit 0", rc == 0 and out.strip() == "", out) - # A file outside the session cwd must not reach across into another - # checkout's book, even when one is cached for it. + # A file in ANOTHER checkout is judged by that checkout's book: its + # scoped rule fires, and the session repo's rule does not. other = mkmain(td, "MainRepo2") - seed_book(base, "MainRepo2", [dict(RULE, _scope_repos=["MainRepo2"])]) - rc, out = run("pre", {"session_id": "o1", "cwd": container, "tool_name": "Write", + seed_book(base, "MainRepo2", [dict(RULE, id="metered-llm-2", _scope_repos=["MainRepo2"]), + {"id": "no-push-2", "on": "bash", "rx": r"\bgit\s+push\b", + "_scope_repos": ["MainRepo2"], "text": "MainRepo2 pushes go via CI", + "why": "w"}]) + rc, out = run("pre", {"session_id": "o1", "cwd": wt, "tool_name": "Write", "tool_input": {"file_path": os.path.join(other, "app", "x.py"), "content": BODY}}, env) - check("a file outside the session cwd loads no book and stays silent", - rc == 0 and out.strip() == "", out) + check("an Edit of a file in another repo is judged by that repo's book", + rc == 0 and "[metered-llm-2]" in ctx(out) and "[metered-llm]" not in ctx(out), out) + rc, out = run("pre", {"session_id": "o2", "cwd": wt, "tool_name": "Bash", + "tool_input": {"command": "cd ~/MainRepo2 && git push"}}, + dict(env, HOME=td)) + check("`cd ~/<other repo> && git push` fires the other repo's scoped rule", + rc == 0 and "[no-push-2]" in ctx(out), out) + rc, out = run("pre", {"session_id": "o3", "cwd": wt, "tool_name": "Bash", + "tool_input": {"command": "git push"}}, dict(env, HOME=td)) + check("…and the same push in the session's own repo does not", + rc == 0 and "[no-push-2]" not in ctx(out), out) + + # A Bash command that WRITES or READS a file in another repo's checkout, + # with no `cd` (the call stays the session repo's), has that file + # judged by the other repo's book — the live repro of PR #176's review. + repo_a = mkmain(td, "RepoA") # the session's repo, no rules + seed_book(base, "MainRepo2", [ + {"id": "no-data-files", "on": "edit", "path_rx": r"(^|/)[^/]*\.(csv|tsv)$", + "text": "Never commit data files to the repo", "why": "w"}, + {"id": "meter", "on": "edit", "path_rx": r"^(?:.*/)?app/.*\.py$", + "content_rx": r"(AsyncOpenAI|AsyncAnthropic)\(\s*api_key", "text": "Every model call reaches the meter", + "why": "w"}, + {"id": "no-meter-gate", "on": "edit", "mode": "gate", "path_rx": r"app/gated/.*\.py$", + "content_rx": r"AsyncOpenAI\(", "text": "Gated meter", "why": "w"}, + {"id": "readme-read", "on": "read", "path_rx": r"README\.md$", "text": "Read the README via the brain", + "why": "w"}, + {"id": "no-push-2", "on": "bash", "rx": r"\bgit\s+push\b", "_scope_repos": ["MainRepo2"], + "text": "MainRepo2 pushes go via CI", "why": "w"}]) + henv = dict(env, HOME=td) + for n, cmd, want in [ + (1, "echo a,b > ../MainRepo2/live_probe.csv", "[no-data-files]"), + (2, "tee $HOME/MainRepo2/app/services/live_probe_llm.py >/dev/null <<'PY'\n" + "client = AsyncOpenAI(api_key=key)\nPY", "[meter]"), + (3, "cat ~/MainRepo2/README.md", "[readme-read]"), + (4, f"printf 'x,y' > {other}/data/t.tsv", "[no-data-files]")]: + rc, out = run("pre", {"session_id": f"x{n}", "cwd": repo_a, "tool_name": "Bash", + "tool_use_id": f"x{n}", "tool_input": {"command": cmd}}, henv) + check(f"a Bash write/read into another repo is judged by that repo's book ({want})", + rc == 0 and want in ctx(out), out) + rc, out = run("pre", {"session_id": "x5", "cwd": repo_a, "tool_name": "Bash", + "tool_input": {"command": "cat > ~/MainRepo2/app/gated/m.py <<'EOF'\n" + "c = AsyncOpenAI()\nEOF"}}, henv) + check("…and that repo's gate-mode edit rule blocks the command", + rc == 0 and '"permissionDecision": "deny"' in out and "[no-meter-gate]" in out, out) + rc, out = run("pre", {"session_id": "x6", "cwd": repo_a, "tool_name": "Bash", + "tool_input": {"command": "echo a,b > ../MainRepo2/x.csv && git push"}}, henv) + check("…while the command's own rules stay the session repo's (no MainRepo2 push rule)", + "[no-data-files]" in ctx(out) and "[no-push-2]" not in ctx(out), out) + with open(os.path.join(base, "ledger", "fires.jsonl"), encoding="utf-8") as f: + rows = [json.loads(line) for line in f if '"x1"' in line] + check("…and the fire is recorded against the repo whose book fired it", + [r["repo"] for r in rows] == ["MainRepo2"], str(rows)) # An edit that does not match still says nothing — the fix widens where # rules are LOOKED for, never what counts as a match. diff --git a/tests/starter_rulebook_test.py b/tests/starter_rulebook_test.py index 888445f6..6fb3259a 100644 --- a/tests/starter_rulebook_test.py +++ b/tests/starter_rulebook_test.py @@ -297,10 +297,12 @@ def test_the_skill_asks_before_it_reads_and_warns_before_it_waits() -> None: check("--all is only ever the person's ask", "only when they ask for all of" in skill) check("one scope for the whole batch, decided before anything is filed", "one scope for the\nwhole batch" in skill) - check("…org-wide unless the person says otherwise, without asking", - "**Otherwise `org`** — do not ask" in skill) + check("…just the person unless the batch is plainly for everyone, without asking", + "**Otherwise `personal`** —" in skill and "Do not ask," in skill) + check("…and an explicit 'just me' is never escalated", + '"just me" / "only me" / "personal" → `personal`' in skill) check("…and the workspace scope only when they ask for it", - "never offer the\nworkspace scope unprompted" in skill) + "never offer the workspace scope unprompted" in skill) check("the skill never makes a rulebook", "create_rulebook" not in skill) @@ -710,6 +712,126 @@ def _verify(mutate): p.returncode == 0 and "why" not in body and body["when"] and body["do"]) +def _run_args(repo: Path, out: Path, *extra: str): + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_PLUGIN_ROOT"} + p = subprocess.run([sys.executable, str(SCRIPT), "all", "--repo", str(repo), "--out", str(out), *extra], + capture_output=True, text=True, env=env, timeout=300) + load = lambda n: json.loads((out / n).read_text()) + return p, load("signals.json"), load("candidates.json") + + +def _verify_body(body: dict, fires: list, silent: list) -> tuple[bool, list]: + sys.path.insert(0, str(ROOT / "plugins" / "memhub" / "scripts")) + import rulebook_verify as V + ok, _, lines = V.verify(body, fires, silent) + return ok, lines + + +def test_a_test_run_launched_the_usual_ways_clears_the_push_gate() -> None: + """The receipt knew only bare `pytest`; on one repo 2,082 of 2,290 runs were + `uv run python -m pytest`, so the gate never cleared and people learned to override it.""" + with tempfile.TemporaryDirectory() as tmp: + repo = _make(PYTHON_REPO, Path(tmp) / "svc") + p, signals, cands = _run_args(repo, Path(tmp) / "out") + body = {c["id"]: c["body"] for c in cands}["suite-before-push"] + ran = ["uv run python -m pytest tests/", "uv run --extra dev pytest -q", ".venv/bin/pytest -q", + ".venv/bin/python3.12 -m pytest", "poetry run pytest", "$PY -m pytest tests/ -q", "uvx pytest"] + ok, lines = _verify_body(body, fires=["edit:src/a.py >> ok:uv run python -m pytest --version >> gate:git push", + "edit:src/a.py >> ok:grep -rn pytest docs/ >> gate:git push"], + silent=["edit:src/a.py >> ok:%s >> gate:git push" % r for r in ran]) + check("every launcher form clears suite-before-push; --version and a grep do not", ok, "\n".join(lines)) + + +def test_a_linter_launched_the_usual_ways_clears_the_lint_gate() -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = _make(PYTHON_REPO, Path(tmp) / "svc") + p, signals, cands = _run_args(repo, Path(tmp) / "out") + body = {c["id"]: c["body"] for c in cands}["lint-before-push"] + ok, lines = _verify_body(body, fires=["edit:src/a.py >> ok:uv run ruff --version >> gate:git push"], + silent=["edit:src/a.py >> ok:%s >> gate:git push" % r for r in + ("uv run ruff check .", "uvx ruff check .", ".venv/bin/mypy app", + "uv run python -m mypy app", "ruff format --check .")]) + check("every launcher form clears lint-before-push", ok, "\n".join(lines)) + + +def test_the_cap_is_the_hooks_own() -> None: + """The starter cuts patterns to the hook's load cap, read from the hook — never a copy that can + go stale (one sat at 400 while the hook loaded 2000).""" + sys.path.insert(0, str(SCRIPT.parent)) + sys.path.insert(0, str(SKILL.parents[1] / "scripts")) + import rulebook_hook as H + import starter_rulebook as S + check("starter RX_MAX is the hook's _RX_MAX", S.RX_MAX == H._RX_MAX, "%s vs %s" % (S.RX_MAX, H._RX_MAX)) + + +def test_every_toolchain_mix_keeps_its_receipts_loadable() -> None: + """Python + two Node runners + Go + Rust: both receipts keep the full Python launcher and still + load.""" + files = {**PYTHON_REPO, **NODE_REPO, "go.mod": "module x\n", "a_test.go": "package x\n", + "Cargo.toml": "[package]\nname = 'x'\n", "src/lib.rs": "\n", "Makefile": "test:\n\tpytest\n"} + with tempfile.TemporaryDirectory() as tmp: + p, signals, cands = _run_args(_make(files, Path(tmp) / "poly"), Path(tmp) / "out") + slots = signals["slots"] + check("exit 0, every rule verifies", p.returncode == 0, p.stdout[-400:]) + sys.path.insert(0, str(SCRIPT.parent)) + import starter_rulebook as S + check("both receipts fit the hook's cap with CMD in front", + all(len(slots["CMD"]) + len(slots[k]) <= S.RX_MAX for k in ("test_receipt_rx", "lint_receipt_rx"))) + check("both receipts carry the full launcher", + all(slots[k].count(S.PY_LAUNCH) == 1 for k in ("test_receipt_rx", "lint_receipt_rx")), + str([slots[k] for k in ("test_receipt_rx", "lint_receipt_rx")])) + + +def test_a_linter_only_the_ci_runs_is_still_evidence() -> None: + files = {"pyproject.toml": '[project]\nname = "svc"\n', "app/a.py": "x = 1\n", + ".github/workflows/lint.yml": "jobs:\n l:\n steps:\n - run: uvx ruff check .\n"} + with tempfile.TemporaryDirectory() as tmp: + p, signals, _ = _run_args(_make(files, Path(tmp) / "ci"), Path(tmp) / "out") + check("ruff run by CI is found", "ruff check ." in (signals["signals"]["lint"].get("value") or []), + signals["signals"]["lint"]["evidence"]) + + +def _commits(repo: Path, branch: str, subjects: list, start: str = "trunk") -> None: + env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull) + g = lambda *a: subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t", *a], + check=True, capture_output=True, env=env) + g("checkout", "-q", "-b", branch, start) + for s in subjects: + g("commit", "-q", "--allow-empty", "-m", s) + g("update-ref", "refs/remotes/origin/" + branch, "HEAD") + g("checkout", "-q", "trunk") + + +def test_the_branch_pull_requests_land_on_is_named_and_one_flag_guards_it() -> None: + """origin/HEAD is `trunk`, but the team merges every PR into `staging`. The scan names staging + (a hint, not a slot) and does NOT name a feature branch whose tip a merge bot versioned.""" + with tempfile.TemporaryDirectory() as tmp: + repo = _make(BARE_REPO, Path(tmp) / "flow") + _commits(repo, "staging", ["feat: a (#1)", "fix: b (#2)", "Merge pull request #3 from acme/c", "feat: d (#4)"]) + _commits(repo, "fm-feat", ["wip", "more work", "address review", "chore: version 1.2.0 (#9)"]) + p, signals, cands = _run_args(repo, Path(tmp) / "out") + ev = signals["signals"]["branch"] + check("the hint names staging and not the bot-versioned feature branch", + ev.get("also_landing") == ["staging"], str(ev)) + check("…and only names it: the guarded branch is still origin/HEAD alone", + signals["slots"]["default_branch_rx"] == "^(trunk)$") + check("…and says the flag to pass", "--default-branch trunk --default-branch staging" in ev["evidence"]) + + p, signals, cands = _run_args(repo, Path(tmp) / "out2", "--default-branch", "trunk", + "--default-branch", "staging") + check("exit 0 with two branches", p.returncode == 0, p.stdout[-400:]) + check("one flag fills every branch slot", signals["slots"]["default_branch_rx"] == "^(trunk|staging)$" + and signals["slots"]["default_branch"] == "trunk") + body = {c["id"]: c["body"] for c in cands} + rx = body["push-main-refspec"]["matcher"]["command_rx"] + for cmd in ("git push origin HEAD:staging", "git push origin trunk"): + check(f"refspec rule fires on `{cmd}`", bool(re.search(rx, cmd))) + check("…and not on a feature branch", not re.search(rx, "git push origin HEAD:fm-feat")) + ok, lines = _verify_body(body["push-main"], fires=[{"case": "git push", "branch": "staging"}], + silent=[{"case": "git push", "branch": "fm-feat"}]) + check("push-main guards a push made from staging", ok, "\n".join(lines)) + + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_") and callable(fn):