Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions tests/claude_hook_guard_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 7 additions & 5 deletions tests/codex_rulebook_context_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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'
Expand Down
65 changes: 64 additions & 1 deletion tests/cursor_capture_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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):
Expand Down
45 changes: 26 additions & 19 deletions tests/documentation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"):
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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")
Expand All @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add add_memory to the removed-tool scan

When a shipped script or skill mentions add_memory outside allowed-tools, deleting it from _MCP_SURFACE does not catch the stale reference: test_shipped_files_name_no_removed_mcp_tool builds its regex exclusively from _REMOVED_MCP, where add_memory was not added. This leaves the newly retired API without the regression protection described above and can allow instructions that teach agents a server-refused call.

Useful? React with 👍 / 👎.

"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",
Expand All @@ -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:
Expand Down
Loading
Loading